Files
tiantian-system/app/pages/developer/resources/databases.vue
2026-04-08 17:10:58 +08:00

509 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>