You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

341 lines
14 KiB

<template>
<div>
<!-- 列表视图 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">环境节点(部署服务器)</h2>
<p class="text-sm text-slate-400 mt-1">部署服务器节点(如 上海 / 日本 / 新加坡):远端 IP、SSH 登录账号与私钥、私有镜像仓库、区域共享基础设施。应用管理里「部署到目标环境」从这里选择、两边对应;下一期即可从页面直接更新服务/部署应用。密钥加密存储,不回显明文。</p>
</div>
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增节点</button>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table table-zebra text-sm">
<thead>
<tr>
<th>ID</th><th>名称</th><th>区域码</th><th>SSH 目标</th><th>镜像仓库</th><th>启用</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!rows.length">
<td colspan="7" class="text-center text-slate-400 py-8">暂无环境</td>
</tr>
<tr v-for="e in rows" :key="e.id">
<td class="font-mono">{{ e.id }}</td>
<td class="font-medium">{{ e.name || '' }}</td>
<td>{{ e.code || '' }}</td>
<td class="font-mono text-xs text-slate-500">{{ sshTarget(e) }}</td>
<td class="text-xs text-slate-500">{{ imageLabel(e) }}</td>
<td>
<span v-if="e.enabled" class="text-green-600">是</span>
<span v-else class="text-slate-400">否</span>
</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(e)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delEnv(e.id)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑节点' : '新增节点' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl">
<form @submit.prevent="saveEnv">
<!-- 基本 -->
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">名称 *</span></label>
<input v-model="form.name" type="text" class="input input-bordered w-full" required placeholder="如 上海生产" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">区域码</span></label>
<input v-model="form.code" type="text" class="input input-bordered w-full" list="env-code-list" placeholder="shanghai / japan / singapore" />
<datalist id="env-code-list">
<option value="shanghai" /><option value="japan" /><option value="singapore" />
</datalist>
</div>
</div>
<!-- SSH 部署目标 -->
<div class="divider text-xs text-slate-400 mt-6">SSH 部署目标</div>
<div class="grid grid-cols-3 gap-4">
<div class="form-control col-span-2">
<label class="label"><span class="label-text font-medium">SSH 主机</span></label>
<input v-model="form.ssh_host" type="text" class="input input-bordered w-full" placeholder="如 10.0.0.1" @input="resetTest" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">端口</span></label>
<input v-model.number="form.ssh_port" type="number" class="input input-bordered w-full" placeholder="22" @input="resetTest" />
</div>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">SSH 用户</span></label>
<input v-model="form.ssh_user" type="text" class="input input-bordered w-full" placeholder="root" @input="resetTest" />
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">SSH 私钥 (PEM)</span></label>
<textarea v-model="form.ssh_private_key" class="textarea textarea-bordered w-full font-mono text-xs h-28" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" @input="resetTest"></textarea>
<span class="text-xs text-slate-400 mt-1">加密存储;编辑时显示 [encrypted] 表示不改,如需更换直接粘贴新私钥。</span>
</div>
<!-- 私有镜像仓库 + Docker 网络(节点级共享)-->
<div class="divider text-xs text-slate-400 mt-6">私有镜像仓库 + Docker 网络</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">仓库地址</span></label>
<input v-model="form.registry" type="text" class="input input-bordered w-full" placeholder="registry.ymaikj.com" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Docker 网络</span></label>
<input v-model="form.docker_network" type="text" class="input input-bordered w-full" placeholder="1panel-network" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">仓库账号</span></label>
<input v-model="form.registry_user" type="text" class="input input-bordered w-full" autocomplete="off" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">仓库密码</span></label>
<input v-model="form.registry_pass" type="password" class="input input-bordered w-full" autocomplete="new-password" placeholder="[encrypted]" />
</div>
</div>
<!-- 节点共享集群服务 -->
<div class="divider text-xs text-slate-400 mt-6">节点共享集群服务</div>
<p class="text-xs text-slate-400 -mt-2 mb-2">该服务器上各应用共用的地址。各应用自己的部署目录、业务库 DSN、镜像、redis 库号等,在「应用管理 → 部署到目标环境」里按应用填。</p>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">ETCD</span></label>
<input v-model="form.etcd_servers" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="etcd:2379" @input="resetTest" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">NATS</span></label>
<input v-model="form.nats_url" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="nats://nats:4222" @input="resetTest" />
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Redis 地址</span></label>
<input v-model="form.redis_addr" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="redis:6379" @input="resetTest" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Redis 密码</span></label>
<input v-model="form.redis_pwd" type="password" class="input input-bordered w-full" autocomplete="new-password" placeholder="[encrypted]" />
</div>
</div>
<!-- 备注 + 启用 -->
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">备注</span></label>
<input v-model="form.remark" type="text" class="input input-bordered w-full" />
</div>
<div class="flex items-center gap-2 mt-4">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="form.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="text-sm">启用</span>
</label>
</div>
<!-- 测试结果 -->
<div v-show="testResult.show" class="mt-4 text-sm space-y-0.5">
<div v-for="it in testItems" :key="it.key" :class="it.ok ? 'text-green-700' : 'text-red-600'">
<span :class="it.ok ? 'text-green-600' : 'text-red-500'">{{ it.ok ? '✓' : '✗' }}</span>
{{ it.label }}:{{ it.msg }}
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="button" class="btn btn-outline" :disabled="testing" @click="testConn">{{ testing ? '测试中...' : '测试连接' }}</button>
<button type="submit" class="btn btn-primary text-white" :disabled="!testPassed">保存</button>
</div>
<p class="text-xs text-slate-400 mt-2">需先「测试连接」(SSH 握手成功即可保存;基础设施逐项仅作提示)。</p>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '环境节点' })
interface EnvItem {
id: number
name: string
code: string
remark: string
enabled: boolean
ssh_host: string
ssh_port: number
ssh_user: string
ssh_private_key: string
registry: string
registry_user: string
registry_pass: string
docker_network: string
etcd_servers: string
redis_addr: string
redis_pwd: string
nats_url: string
}
interface EnvTestResp {
ok: boolean
ssh_ok: boolean; ssh_msg: string
etcd_ok: boolean; etcd_msg: string
redis_ok: boolean; redis_msg: string
nats_ok: boolean; nats_msg: string
}
const { consoleApi } = useApi()
const { success, error } = useToast()
const view = ref<'list' | 'form'>('list')
const rows = ref<EnvItem[]>([])
const loading = ref(false)
const isEditing = ref(false)
const testPassed = ref(false)
const testing = ref(false)
const testResult = reactive<EnvTestResp & { show: boolean }>({
show: false,
ok: false,
ssh_ok: false, ssh_msg: '',
etcd_ok: false, etcd_msg: '',
redis_ok: false, redis_msg: '',
nats_ok: false, nats_msg: '',
})
const testItems = computed(() => [
{ key: 'ssh', label: 'SSH 部署机', ok: testResult.ssh_ok, msg: testResult.ssh_msg },
{ key: 'etcd', label: 'ETCD', ok: testResult.etcd_ok, msg: testResult.etcd_msg },
{ key: 'redis', label: 'Redis', ok: testResult.redis_ok, msg: testResult.redis_msg },
{ key: 'nats', label: 'NATS', ok: testResult.nats_ok, msg: testResult.nats_msg },
])
function blankForm(): EnvItem {
return {
id: 0, name: '', code: '', remark: '', enabled: true,
ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_private_key: '',
registry: '', registry_user: '', registry_pass: '', docker_network: '1panel-network',
etcd_servers: '', redis_addr: '', redis_pwd: '', nats_url: '',
}
}
const form = reactive<EnvItem>(blankForm())
function assignForm(src: EnvItem) {
Object.assign(form, blankForm(), src)
}
function sshTarget(e: EnvItem) {
if (!e.ssh_host) return '-'
return `${e.ssh_user || 'root'}@${e.ssh_host}:${e.ssh_port || 22}`
}
function imageLabel(e: EnvItem) {
return e.registry || '-'
}
async function loadData() {
loading.value = true
try {
const data = await consoleApi<EnvItem[]>('envs/list', {})
rows.value = data ?? []
} catch (e: any) {
error(e?.message ?? '获取环境列表失败')
rows.value = []
} finally {
loading.value = false
}
}
function resetTest() {
testPassed.value = false
testResult.show = false
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(e?: EnvItem) {
if (e) {
isEditing.value = true
assignForm(e)
} else {
isEditing.value = false
assignForm(blankForm())
}
resetTest()
view.value = 'form'
}
async function testConn() {
if (!form.ssh_host.trim()) {
error('请先填 SSH 主机')
return
}
testing.value = true
try {
const d = await consoleApi<EnvTestResp>('envs/test', { ...form })
Object.assign(testResult, d, { show: true })
testPassed.value = !!d.ok
if (testPassed.value) success('SSH 握手成功,可以保存')
else error('SSH 未通过,请检查主机/私钥/用户')
} catch (e: any) {
error(e?.message ?? '测试失败')
} finally {
testing.value = false
}
}
async function saveEnv() {
if (!testPassed.value) {
error('请先「测试连接」通过再保存')
return
}
const data: Record<string, unknown> = { ...form }
let path = 'envs/add'
if (isEditing.value && form.id) {
data.id = Number(form.id)
path = 'envs/update'
} else {
delete data.id
}
try {
await consoleApi(path, data)
success('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '失败')
}
}
function delEnv(id: number) {
if (!window.confirm(`删除环境 #${id} ?(该环境下若有应用关联将被拒绝)`)) return
consoleApi('envs/del', { id })
.then(() => { success('已删除'); loadData() })
.catch((e: any) => error(e?.message ?? '失败'))
}
onMounted(loadData)
</script>