初始化2
This commit is contained in:
508
app/pages/developer/resources/databases.vue
Normal file
508
app/pages/developer/resources/databases.vue
Normal file
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<div class="page-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<a-select v-model:value="selectedAppId" placeholder="全部应用" allow-clear style="width: 180px" @change="loadList">
|
||||
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">{{ app.productName }}</a-select-option>
|
||||
</a-select>
|
||||
<PermissionGuard :app-id="selectedAppId" permission="canEditResource" :show-tip="true">
|
||||
<a-button type="primary" @click="showAdd = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加数据库
|
||||
</a-button>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索数据库名称 / 地址"
|
||||
style="width: 240px"
|
||||
@search="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice-bar">
|
||||
<InfoCircleOutlined class="notice-icon" />
|
||||
<span>在此统一管理应用使用的数据库实例。选择服务器后,添加数据库将自动在远程服务器上创建。</span>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="resourceId"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dbType'">
|
||||
<a-tag :color="typeColor[record.dbType]">{{ record.dbType }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'port'">
|
||||
{{ record.port || '-' }}
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="statusBadge[record.status] || 'default'"
|
||||
:text="statusLabel[record.status] || record.status"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="record.status === 'failed' && (record.isOwner || getAppPermission(record.appId)?.canEditResource)"
|
||||
type="link"
|
||||
size="small"
|
||||
:loading="retryingId === record.resourceId"
|
||||
@click="handleRetry(record)"
|
||||
>
|
||||
重试
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="record.isOwner || getAppPermission(record.appId)?.canEditResource"
|
||||
title="确定要重置密码吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleResetPassword(record)"
|
||||
>
|
||||
<a-button type="link" size="small" :loading="resettingId === record.resourceId">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
重置密码
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-divider v-if="record.isOwner || getAppPermission(record.appId)?.canEditResource" type="vertical" />
|
||||
<a-popconfirm v-if="record.isOwner || getAppPermission(record.appId)?.canEditResource" title="确定要移除此数据库?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>移除</a-button>
|
||||
</a-popconfirm>
|
||||
<!-- 无权限:只读状态 -->
|
||||
<span v-if="!record.isOwner && !getAppPermission(record.appId)?.canEditResource" style="font-size: 12px; color: #faad14;">
|
||||
<LockOutlined /> 只读
|
||||
</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showAdd"
|
||||
title="添加数据库"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="saveLoading"
|
||||
width="560px"
|
||||
@ok="handleSave"
|
||||
@cancel="resetForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" style="margin-top: 8px">
|
||||
<!-- 所属服务器 -->
|
||||
<a-form-item label="所属服务器" name="serverResourceId">
|
||||
<a-select
|
||||
v-model:value="form.serverResourceId"
|
||||
placeholder="选择服务器(用于远程建库)"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="filterServerOption"
|
||||
@change="handleServerChange"
|
||||
>
|
||||
<a-select-option v-for="s in serverOptions" :key="s.resourceId" :value="s.resourceId">
|
||||
{{ s.name }}({{ s.ip }} / MySQL:{{ s.mysqlPort || 3306 }} / PG:{{ s.pgPort || 5432 }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div v-if="form.serverResourceId" class="form-tip">
|
||||
<ApiOutlined /> 将在所选服务器上自动创建数据库
|
||||
</div>
|
||||
<div v-else class="form-tip form-tip-warn">
|
||||
不选择服务器则仅记录信息,不会远程创建数据库
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="数据库名称" name="name">
|
||||
<a-input v-model:value="form.name" placeholder="如:db_shop" />
|
||||
</a-form-item>
|
||||
<a-form-item label="数据库类型" name="dbType">
|
||||
<a-select v-model:value="form.dbType" placeholder="请选择类型">
|
||||
<a-select-option value="MySQL">MySQL</a-select-option>
|
||||
<a-select-option value="PostgreSQL">PostgreSQL</a-select-option>
|
||||
<a-select-option value="Redis">Redis</a-select-option>
|
||||
<a-select-option value="MongoDB">MongoDB</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="连接地址" name="host">
|
||||
<a-input v-model:value="form.host" placeholder="Host / IP 地址(选择服务器后自动填充)" :disabled="!!form.serverResourceId" />
|
||||
</a-form-item>
|
||||
<a-form-item label="端口" name="port">
|
||||
<a-input-number v-model:value="form.port" :min="1" :max="65535" :precision="0" style="width: 100%" placeholder="如:3306" :disabled="!!form.serverResourceId" />
|
||||
</a-form-item>
|
||||
<a-form-item label="用户名" name="dbUsername">
|
||||
<a-input v-model:value="form.dbUsername" placeholder="数据库用户名(选择服务器后将自动创建)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" name="dbPassword">
|
||||
<a-input-password v-model:value="form.dbPassword" placeholder="数据库密码">
|
||||
<template #addonAfter>
|
||||
<a-button type="link" size="small" style="padding: 0; height: auto;" @click="refreshPassword">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-form-item label="关联应用" name="appId">
|
||||
<a-select v-model:value="form.appId" placeholder="可选,关联到应用" allow-clear>
|
||||
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">
|
||||
{{ app.productName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" placeholder="可选备注" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined, InfoCircleOutlined, SyncOutlined, ApiOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { pageAppResource, addAppResource, removeAppResource, retryCreateDatabase, resetDatabasePassword } from '@/api/app/appResource'
|
||||
import { getMyAccessibleApps } from '@/api/app/appProduct'
|
||||
import { useAppPermission } from '@/composables/useAppPermission'
|
||||
import PermissionGuard from '@/components/developer/PermissionGuard.vue'
|
||||
import type { AppResource } from '@/api/app/appResource/model'
|
||||
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
useHead({ title: '数据库管理 - 开发者中心' })
|
||||
|
||||
const loading = ref(false)
|
||||
const saveLoading = ref(false)
|
||||
const resettingId = ref<number | null>(null)
|
||||
const showAdd = ref(false)
|
||||
const searchText = ref('')
|
||||
const retryingId = ref<number | null>(null)
|
||||
const formRef = ref()
|
||||
const selectedAppId = ref<number | undefined>(undefined)
|
||||
const { getAppPermission } = useAppPermission()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
dbType: undefined as string | undefined,
|
||||
serverResourceId: undefined as number | undefined,
|
||||
host: '',
|
||||
port: undefined as number | undefined,
|
||||
dbUsername: '',
|
||||
dbPassword: '',
|
||||
appId: undefined as number | undefined,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 生成随机密码(12位,包含大小写字母、数字、特殊字符)
|
||||
function generateRandomPassword(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'
|
||||
let password = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
// 监听数据库名称变化,自动填充用户名和密码
|
||||
watch(() => form.name, (newName) => {
|
||||
if (newName) {
|
||||
form.dbUsername = newName
|
||||
if (!form.dbPassword) {
|
||||
form.dbPassword = generateRandomPassword()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据库类型变化,如果已选服务器则自动刷新端口
|
||||
watch(() => form.dbType, () => {
|
||||
if (form.serverResourceId) {
|
||||
handleServerChange(form.serverResourceId)
|
||||
}
|
||||
})
|
||||
|
||||
// 校验连接地址
|
||||
function validateHost(_rule: any, value: string) {
|
||||
if (!value) return Promise.reject('请输入连接地址')
|
||||
if (/[a-zA-Z\-]/.test(value)) return Promise.resolve()
|
||||
const ipv4Reg = /^(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)\.(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)\.(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)\.(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)$/
|
||||
if (ipv4Reg.test(value)) return Promise.resolve()
|
||||
if (/^[\d.]+$/.test(value) && value.includes('.'))
|
||||
return Promise.reject('IPv4 地址格式不正确(如:192.168.1.1)')
|
||||
if (value.includes(':')) {
|
||||
const ipv6Reg = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?::(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?$/
|
||||
if (ipv6Reg.test(value)) return Promise.resolve()
|
||||
return Promise.reject('IPv6 地址格式不正确')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 校验端口号
|
||||
function validatePort(_rule: any, value: number) {
|
||||
if (value === undefined || value === null || value === '') return Promise.resolve()
|
||||
const num = Number(value)
|
||||
if (isNaN(num) || !Number.isInteger(num) || num < 1 || num > 65535) {
|
||||
return Promise.reject('端口号须为 1 ~ 65535 之间的整数')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 校验数据库名称
|
||||
function validateDbName(_rule: any, value: string) {
|
||||
if (!value) return Promise.reject('请输入数据库名称')
|
||||
const dbNameReg = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
|
||||
if (!dbNameReg.test(value)) {
|
||||
return Promise.reject('命名格式:db_shop(小写字母开头,下划线分隔,仅含小写字母、数字、下划线)')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入数据库名称' },
|
||||
{ validator: validateDbName, trigger: 'blur' },
|
||||
],
|
||||
dbType: [{ required: true, message: '请选择数据库类型' }],
|
||||
host: [
|
||||
{ required: true, message: '请输入连接地址' },
|
||||
{ validator: validateHost, trigger: 'blur' },
|
||||
],
|
||||
port: [{ validator: validatePort, trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
running: '运行中',
|
||||
pending: '创建中',
|
||||
failed: '创建失败',
|
||||
stopped: '已停止',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
running: 'success',
|
||||
pending: 'processing',
|
||||
failed: 'error',
|
||||
stopped: 'default',
|
||||
expired: 'warning',
|
||||
}
|
||||
|
||||
const typeColor: Record<string, string> = {
|
||||
MySQL: 'blue',
|
||||
PostgreSQL: 'purple',
|
||||
Redis: 'red',
|
||||
MongoDB: 'green',
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '数据库名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'dbType', key: 'dbType' },
|
||||
{ title: '连接地址', dataIndex: 'host', key: 'host' },
|
||||
{ title: '端口', dataIndex: 'port', key: 'port' },
|
||||
{ title: '用户名', dataIndex: 'dbUsername', key: 'dbUsername', customRender: ({ record }: any) => {
|
||||
if ((record.accessLevel ?? 1) >= 2) return record.dbUsername || '-'
|
||||
return '***'
|
||||
} },
|
||||
// { title: '关联应用', dataIndex: 'productName', key: 'productName' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '操作', key: 'action', width: 180 },
|
||||
]
|
||||
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
const list = ref<AppResource[]>([])
|
||||
const appOptions = ref<any[]>([])
|
||||
const serverOptions = ref<any[]>([])
|
||||
|
||||
// 服务器搜索过滤
|
||||
function filterServerOption(input: string, option: any) {
|
||||
const label = option.children?.[0]?.children?.toString() || ''
|
||||
return label.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
|
||||
// 选择服务器后自动填充 host/port
|
||||
function handleServerChange(serverResourceId: number | undefined) {
|
||||
if (serverResourceId) {
|
||||
const server = serverOptions.value.find((s: any) => s.resourceId === serverResourceId)
|
||||
if (server) {
|
||||
form.host = server.ip
|
||||
// 根据数据库类型填充对应端口
|
||||
if (form.dbType === 'PostgreSQL') {
|
||||
form.port = server.pgPort || 5432
|
||||
} else {
|
||||
form.port = server.mysqlPort || 3306
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载应用下拉列表(仅当前用户可访问的应用)
|
||||
async function loadAppOptions() {
|
||||
try {
|
||||
appOptions.value = await getMyAccessibleApps()
|
||||
}
|
||||
catch {
|
||||
appOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载服务器下拉列表(只加载有管理员凭据的服务器)
|
||||
async function loadServerOptions() {
|
||||
try {
|
||||
const res = await pageAppResource({ resourceType: 'server', page: 1, limit: 200 })
|
||||
const servers = res?.list ?? []
|
||||
// 只展示配置了管理员用户名的服务器
|
||||
serverOptions.value = servers.filter((s: any) => s.adminUsername)
|
||||
}
|
||||
catch {
|
||||
serverOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await pageAppResource({
|
||||
resourceType: 'database',
|
||||
keywords: searchText.value || undefined,
|
||||
appId: selectedAppId.value,
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
})
|
||||
list.value = enrichResourcesWithPermission(result?.list ?? [])
|
||||
pagination.total = result?.count ?? 0
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
loadList()
|
||||
}
|
||||
|
||||
async function handleDelete(record: AppResource) {
|
||||
try {
|
||||
if (record.serverResourceId && (record.dbType === 'MySQL' || record.dbType === 'PostgreSQL')) {
|
||||
message.loading({ content: '正在删除远程数据库...', key: 'deleteDb' })
|
||||
}
|
||||
await removeAppResource(record.resourceId!)
|
||||
message.success({ content: '已移除', key: 'deleteDb' })
|
||||
loadList()
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error({ content: e.message || '删除失败', key: 'deleteDb' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(record: AppResource) {
|
||||
if (!record.resourceId) return
|
||||
retryingId.value = record.resourceId
|
||||
try {
|
||||
await retryCreateDatabase(record.resourceId)
|
||||
message.success('已开始重新创建,请稍后刷新查看状态')
|
||||
// 3秒后自动刷新
|
||||
setTimeout(loadList, 3000)
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '重试失败')
|
||||
}
|
||||
finally {
|
||||
retryingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
async function handleResetPassword(record: AppResource) {
|
||||
if (!record.resourceId) return
|
||||
resettingId.value = record.resourceId
|
||||
try {
|
||||
const result = await resetDatabasePassword(record.resourceId)
|
||||
message.success(`密码已重置为:${result.password}(请妥善保管)`)
|
||||
loadList()
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '重置失败')
|
||||
}
|
||||
finally {
|
||||
resettingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await formRef.value?.validate()
|
||||
saveLoading.value = true
|
||||
try {
|
||||
const payload: AppResource = {
|
||||
resourceType: 'database',
|
||||
name: form.name,
|
||||
dbType: form.dbType,
|
||||
serverResourceId: form.serverResourceId ? Number(form.serverResourceId) : undefined,
|
||||
host: form.host || undefined,
|
||||
port: form.port,
|
||||
dbUsername: form.dbUsername || undefined,
|
||||
dbPassword: form.dbPassword || undefined,
|
||||
appId: form.appId ? Number(form.appId) : undefined,
|
||||
remark: form.remark,
|
||||
}
|
||||
await addAppResource(payload)
|
||||
if (form.serverResourceId && (form.dbType === 'MySQL' || form.dbType === 'PostgreSQL')) {
|
||||
message.success('添加成功,正在远程创建数据库...')
|
||||
// 3秒后刷新状态
|
||||
setTimeout(loadList, 3000)
|
||||
}
|
||||
else {
|
||||
message.success('添加成功')
|
||||
}
|
||||
showAdd.value = false
|
||||
resetForm()
|
||||
loadList()
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formRef.value?.resetFields()
|
||||
Object.assign(form, { name: '', dbType: undefined, serverResourceId: undefined, host: '', port: undefined, dbUsername: '', dbPassword: '', appId: undefined, remark: '' })
|
||||
}
|
||||
|
||||
// 刷新密码
|
||||
function refreshPassword() {
|
||||
form.dbPassword = generateRandomPassword()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAppOptions()
|
||||
loadServerOptions()
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-page { padding: 24px; max-width: 1100px; }
|
||||
.page-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.toolbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.toolbar-right { display: flex; gap: 8px; }
|
||||
.notice-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: #e6f4ff; border: 1px solid #91caff;
|
||||
border-radius: 6px; padding: 8px 14px; margin-bottom: 16px;
|
||||
font-size: 13px; color: #1677ff;
|
||||
}
|
||||
.notice-icon { font-size: 15px; }
|
||||
.form-tip {
|
||||
font-size: 12px; color: #52c41a; margin-top: 4px;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.form-tip-warn { color: #faad14; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user