初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
import { NCheckbox, NInput, NSelect } from 'naive-ui'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { listAvailabilityDomains, listImages, listShapes } from '@/api/instances'
|
||||
import { listSubnets, listVcns } from '@/api/networks'
|
||||
import { listLimits } from '@/api/tenant'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormSection from '@/components/FormSection.vue'
|
||||
import { shortAd } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
/** 实例规格表单(创建实例弹窗与抢机任务共用):
|
||||
* Shape / 可用域 / 规格 / 镜像 / VCN 子网 / 引导卷 / 登录方式 / IP 分配。
|
||||
* 实例名、数量、租户 / 区域 / 区间由父表单提供,经 props 传入作数据源;
|
||||
* sectioned 为 true 时渲染「规格 / 网络与存储 / 登录与 IP」分区标题。 */
|
||||
const props = defineProps<{
|
||||
cfgId: number | null
|
||||
region?: string
|
||||
compartmentId?: string
|
||||
sectioned?: boolean
|
||||
/** 可用域字段说明;抢机任务留空为轮询语义,与手动创建(固定 AD-1)不同 */
|
||||
adHint?: string
|
||||
}>()
|
||||
|
||||
/** VCN 选择的「自动创建」哨兵:提交时不传 subnetId,由后端建 VCN 与子网 */
|
||||
const AUTO_VCN = '__auto__'
|
||||
|
||||
const blank = {
|
||||
availabilityDomain: null as string | null,
|
||||
shape: 'VM.Standard.A1.Flex',
|
||||
ocpus: 4,
|
||||
memoryInGBs: 24,
|
||||
imageId: null as string | null,
|
||||
vcnId: AUTO_VCN,
|
||||
subnetId: null as string | null,
|
||||
bootVolumeSizeGBs: null as number | null,
|
||||
bootVolumeVpusPerGB: 10,
|
||||
assignPublicIp: true,
|
||||
assignIpv6: true,
|
||||
authMode: 'password' as 'password' | 'ssh',
|
||||
randomPassword: true,
|
||||
rootPassword: '',
|
||||
sshPublicKey: '',
|
||||
}
|
||||
const form = reactive({ ...blank })
|
||||
|
||||
const images = useAsync(
|
||||
() =>
|
||||
props.cfgId === null
|
||||
? Promise.resolve([])
|
||||
: listImages(props.cfgId, { region: props.region || undefined, shape: form.shape }),
|
||||
false,
|
||||
)
|
||||
const ads = useAsync(
|
||||
() =>
|
||||
props.cfgId === null
|
||||
? Promise.resolve([])
|
||||
: listAvailabilityDomains(props.cfgId, props.region || undefined),
|
||||
false,
|
||||
)
|
||||
const vcns = useAsync(
|
||||
() =>
|
||||
props.cfgId === null
|
||||
? Promise.resolve([])
|
||||
: listVcns(props.cfgId, props.region || undefined, props.compartmentId || undefined).catch(
|
||||
() => [],
|
||||
),
|
||||
false,
|
||||
)
|
||||
/** 租户当前区域实际提供的 shape 清单(服务端带缓存),失败回退基础清单 */
|
||||
const shapes = useAsync(
|
||||
() =>
|
||||
props.cfgId === null
|
||||
? Promise.resolve([])
|
||||
: listShapes(props.cfgId, props.region || undefined).catch(() => []),
|
||||
false,
|
||||
)
|
||||
/** compute 配额一次全量拉取(服务端单页 1000 条),本地按映射统计各 shape 核数配额 */
|
||||
const limits = useAsync(
|
||||
() =>
|
||||
props.cfgId === null
|
||||
? Promise.resolve([])
|
||||
: listLimits(props.cfgId, { service: 'compute', region: props.region || undefined }).catch(
|
||||
() => [],
|
||||
),
|
||||
false,
|
||||
)
|
||||
const subnets = useAsync(
|
||||
() =>
|
||||
props.cfgId === null || form.vcnId === AUTO_VCN
|
||||
? Promise.resolve([])
|
||||
: listSubnets(props.cfgId, form.vcnId, props.region || undefined).catch(() => []),
|
||||
false,
|
||||
)
|
||||
|
||||
function reloadAll() {
|
||||
if (props.cfgId === null) return
|
||||
void images.run()
|
||||
void ads.run()
|
||||
void vcns.run()
|
||||
void shapes.run()
|
||||
void limits.run()
|
||||
}
|
||||
|
||||
/** 重置表单并按当前租户 / 区域重新加载数据源,父表单打开时调用 */
|
||||
function reset() {
|
||||
Object.assign(form, blank)
|
||||
pendingSubnetId.value = null
|
||||
reloadAll()
|
||||
}
|
||||
|
||||
// 弹窗 / tab 均为按需挂载(display-directive=if),挂载即自加载一次
|
||||
onMounted(reset)
|
||||
|
||||
// 租户 / 区域 / 区间切换(抢机表单内联选择场景):失效选择并重载
|
||||
watch(
|
||||
() => [props.cfgId, props.region, props.compartmentId],
|
||||
() => {
|
||||
form.imageId = null
|
||||
form.vcnId = AUTO_VCN
|
||||
form.subnetId = null
|
||||
form.availabilityDomain = null
|
||||
reloadAll()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.shape,
|
||||
() => {
|
||||
form.imageId = null
|
||||
if (props.cfgId !== null) void images.run()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.vcnId,
|
||||
() => {
|
||||
form.subnetId = null
|
||||
void subnets.run()
|
||||
},
|
||||
)
|
||||
|
||||
// 子网加载后:优先落位编辑回填的子网,否则默认选第一个
|
||||
watch(
|
||||
() => subnets.data.value,
|
||||
(list) => {
|
||||
if (!list?.length) return
|
||||
if (pendingSubnetId.value && list.some((s) => s.id === pendingSubnetId.value)) {
|
||||
form.subnetId = pendingSubnetId.value
|
||||
pendingSubnetId.value = null
|
||||
return
|
||||
}
|
||||
if (!form.subnetId) form.subnetId = list[0].id
|
||||
},
|
||||
)
|
||||
|
||||
/** 编辑任务回填:spec 为 buildSpec 产出的字段集合(payload.instance) */
|
||||
const pendingSubnetId = ref<string | null>(null)
|
||||
|
||||
function applySpec(spec: Record<string, unknown>) {
|
||||
const s = spec as Partial<ReturnType<typeof buildSpec>> & Record<string, unknown>
|
||||
if (typeof s.shape === 'string') form.shape = s.shape
|
||||
if (typeof s.ocpus === 'number') form.ocpus = s.ocpus
|
||||
if (typeof s.memoryInGBs === 'number') form.memoryInGBs = s.memoryInGBs
|
||||
if (typeof s.availabilityDomain === 'string') form.availabilityDomain = s.availabilityDomain
|
||||
if (typeof s.imageId === 'string') form.imageId = s.imageId
|
||||
if (typeof s.bootVolumeSizeGBs === 'number') form.bootVolumeSizeGBs = s.bootVolumeSizeGBs
|
||||
if (typeof s.bootVolumeVpusPerGB === 'number') form.bootVolumeVpusPerGB = s.bootVolumeVpusPerGB
|
||||
form.assignPublicIp = s.assignPublicIp !== false
|
||||
form.assignIpv6 = s.assignIpv6 !== false
|
||||
form.authMode = typeof s.sshPublicKey === 'string' && s.sshPublicKey ? 'ssh' : 'password'
|
||||
form.sshPublicKey = typeof s.sshPublicKey === 'string' ? s.sshPublicKey : ''
|
||||
form.randomPassword = s.generateRootPassword === true || !s.rootPassword
|
||||
form.rootPassword = typeof s.rootPassword === 'string' ? s.rootPassword : ''
|
||||
pendingSubnetId.value = typeof s.subnetId === 'string' ? s.subnetId : null
|
||||
}
|
||||
|
||||
// 回填的子网归属未知:VCN 列表加载后逐个查找包含它的 VCN 并落位
|
||||
watch(
|
||||
() => vcns.data.value,
|
||||
async (list) => {
|
||||
const target = pendingSubnetId.value
|
||||
if (!target || !list?.length || props.cfgId === null) return
|
||||
for (const v of list) {
|
||||
const subs = await listSubnets(props.cfgId, v.id, props.region || undefined).catch(() => [])
|
||||
if (subs.some((s) => s.id === target)) {
|
||||
form.vcnId = v.id // 触发 subnets.run,落位在 subnets 加载 watch 中完成
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingSubnetId.value = null
|
||||
},
|
||||
)
|
||||
|
||||
/** shape → OCI compute 配额名(官方 Limits by Service 命名);已知映射用于配额标注,
|
||||
* 清单外的 shape 不判定配额。AD 维度多行求和 > 0 才认为有配额。 */
|
||||
const SHAPE_QUOTAS: Record<string, string> = {
|
||||
'VM.Standard.A1.Flex': 'standard-a1-core-count',
|
||||
'VM.Standard.A2.Flex': 'standard-a2-core-count',
|
||||
'VM.Standard.A4.Flex': 'standard-a4-core-count',
|
||||
'VM.Standard.E2.1.Micro': 'standard-e2-micro-core-count',
|
||||
'VM.Standard.E3.Flex': 'standard-e3-core-ad-count',
|
||||
'VM.Standard.E4.Flex': 'standard-e4-core-count',
|
||||
'VM.Standard.E5.Flex': 'standard-e5-core-count',
|
||||
'VM.Standard.E6.Flex': 'standard-e6-core-count',
|
||||
'VM.Standard3.Flex': 'standard3-core-count',
|
||||
}
|
||||
|
||||
const BILLING_LABEL: Record<string, string> = {
|
||||
ALWAYS_FREE: '永久免费',
|
||||
LIMITED_FREE: '免费额度',
|
||||
}
|
||||
|
||||
/** 已知映射 shape 的核数配额合计;未知映射或配额未加载返回 null(不判定) */
|
||||
function quotaOf(shape: string): number | null {
|
||||
const quota = SHAPE_QUOTAS[shape]
|
||||
const items = limits.data.value ?? []
|
||||
if (!quota || !items.length) return null
|
||||
return items.filter((l) => l.name === quota).reduce((sum, l) => sum + (l.value > 0 ? l.value : 0), 0)
|
||||
}
|
||||
|
||||
/** 排序:免费 shape 优先 → VM.Standard 系列 → 其他 VM → 裸金属等 */
|
||||
function shapeRank(s: { billingType: string; name: string }): number {
|
||||
if (BILLING_LABEL[s.billingType]) return 0
|
||||
if (s.name.startsWith('VM.Standard')) return 1
|
||||
if (s.name.startsWith('VM.')) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
const shapeOptions = computed(() => {
|
||||
// 清单全部来自 shapes 接口,不做本地预设;接口不可用时仍可手输(tag 模式)
|
||||
const list = shapes.data.value ?? []
|
||||
return [...list]
|
||||
.sort((a, b) => shapeRank(a) - shapeRank(b) || a.name.localeCompare(b.name))
|
||||
.map((s) => {
|
||||
const free = BILLING_LABEL[s.billingType]
|
||||
const quota = quotaOf(s.name)
|
||||
const suffix = (free ? `(${free})` : '') + (quota === 0 ? ' · 无配额' : '')
|
||||
return { label: s.name + suffix, value: s.name, disabled: quota === 0 }
|
||||
})
|
||||
})
|
||||
|
||||
// 清单 / 配额加载后,当前 shape 不在清单或无配额时自动切到第一个可用项
|
||||
watch(
|
||||
() => shapeOptions.value,
|
||||
(opts) => {
|
||||
const cur = opts.find((o) => o.value === form.shape)
|
||||
if (!opts.length || (cur && !cur.disabled)) return
|
||||
const first = opts.find((o) => !o.disabled)
|
||||
if (first) form.shape = String(first.value)
|
||||
},
|
||||
)
|
||||
|
||||
const selectedShape = computed(
|
||||
() => shapes.data.value?.find((s) => s.name === form.shape) ?? null,
|
||||
)
|
||||
/** 清单外(手输 / 未加载)的 shape 按名称后缀判定弹性 */
|
||||
const isFlex = computed(() => selectedShape.value?.isFlexible ?? form.shape.endsWith('.Flex'))
|
||||
|
||||
const ocpuRange = computed(() => ({
|
||||
min: selectedShape.value?.ocpusMin || 1,
|
||||
max: selectedShape.value?.ocpusMax || 128,
|
||||
}))
|
||||
const memRange = computed(() => ({
|
||||
min: selectedShape.value?.memoryMinGBs || 1,
|
||||
max: selectedShape.value?.memoryMaxGBs || 1024,
|
||||
}))
|
||||
|
||||
// 切换 shape 后把已填规格收敛到该 shape 的合法范围
|
||||
watch(selectedShape, (s) => {
|
||||
if (!s?.isFlexible) return
|
||||
form.ocpus = Math.min(Math.max(form.ocpus, ocpuRange.value.min), ocpuRange.value.max)
|
||||
form.memoryInGBs = Math.min(Math.max(form.memoryInGBs, memRange.value.min), memRange.value.max)
|
||||
})
|
||||
|
||||
/** 同名镜像只保留日期最新的一版:去掉尾部 -2026.05.09-0 作分组 key */
|
||||
const imageOptions = computed(() => {
|
||||
const groups = new Map<string, { id: string; displayName: string; timeCreated: string }>()
|
||||
for (const img of images.data.value ?? []) {
|
||||
const key = img.displayName.replace(/-\d{4}\.\d{2}\.\d{2}-\d+$/, '')
|
||||
const prev = groups.get(key)
|
||||
if (!prev || img.timeCreated > prev.timeCreated) groups.set(key, img)
|
||||
}
|
||||
return [...groups.values()]
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
.map((i) => ({ label: i.displayName, value: i.id }))
|
||||
})
|
||||
|
||||
const adOptions = computed(
|
||||
() => ads.data.value?.map((ad) => ({ label: shortAd(ad), value: ad })) ?? [],
|
||||
)
|
||||
|
||||
const vcnOptions = computed(() => [
|
||||
{ label: '自动创建(oci-portal-auto-vcn)', value: AUTO_VCN },
|
||||
...(vcns.data.value ?? []).map((v) => ({ label: v.displayName, value: v.id })),
|
||||
])
|
||||
|
||||
const subnetOptions = computed(
|
||||
() =>
|
||||
subnets.data.value?.map((s) => ({
|
||||
label: `${s.displayName}(${s.cidrBlock}${s.ipv6CidrBlock ? ' · IPv6' : ''})`,
|
||||
value: s.id,
|
||||
})) ?? [],
|
||||
)
|
||||
|
||||
const selectedSubnet = computed(
|
||||
() => subnets.data.value?.find((s) => s.id === form.subnetId) ?? null,
|
||||
)
|
||||
|
||||
/** 自动创建的 VCN 默认启用 IPv6;选现有子网时看其是否有 IPv6 CIDR */
|
||||
const ipv6Supported = computed(
|
||||
() => form.vcnId === AUTO_VCN || !!selectedSubnet.value?.ipv6CidrBlock,
|
||||
)
|
||||
|
||||
// 按子网支持程度自动勾选 / 取消 IPv6
|
||||
watch(ipv6Supported, (v) => {
|
||||
form.assignIpv6 = v
|
||||
})
|
||||
|
||||
const valid = computed(() => {
|
||||
if (!form.imageId) return false
|
||||
if (form.vcnId !== AUTO_VCN && !form.subnetId) return false
|
||||
return !isFlex.value || form.ocpus > 0
|
||||
})
|
||||
|
||||
/** 规格摘要(父表单底部预估条用):shape 短名 + 弹性核内存 + 免费标注 */
|
||||
const summary = computed(() => {
|
||||
const parts = [form.shape.replace(/^VM\.Standard\.?/, '') || form.shape]
|
||||
if (isFlex.value) parts.push(`${form.ocpus} OCPU · ${form.memoryInGBs} GB`)
|
||||
const free = selectedShape.value && BILLING_LABEL[selectedShape.value.billingType]
|
||||
if (free) parts.push(free)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
/** 产出规格字段(不含 displayName / count / region / compartmentId,由父表单拼接) */
|
||||
function buildSpec(): Record<string, unknown> {
|
||||
const usePwd = form.authMode === 'password'
|
||||
return {
|
||||
availabilityDomain: form.availabilityDomain ?? undefined,
|
||||
shape: form.shape,
|
||||
ocpus: isFlex.value ? form.ocpus : undefined,
|
||||
memoryInGBs: isFlex.value ? form.memoryInGBs : undefined,
|
||||
imageId: form.imageId ?? undefined,
|
||||
subnetId: form.vcnId === AUTO_VCN ? undefined : (form.subnetId ?? undefined),
|
||||
bootVolumeSizeGBs: form.bootVolumeSizeGBs ?? undefined,
|
||||
// 性能独立于大小:留空按镜像默认大小时同样生效(后端仅镜像启动源使用)
|
||||
bootVolumeVpusPerGB: form.bootVolumeVpusPerGB || undefined,
|
||||
assignPublicIp: form.assignPublicIp,
|
||||
assignIpv6: form.assignIpv6,
|
||||
generateRootPassword: usePwd && form.randomPassword ? true : undefined,
|
||||
rootPassword:
|
||||
usePwd && !form.randomPassword && form.rootPassword ? form.rootPassword : undefined,
|
||||
sshPublicKey:
|
||||
form.authMode === 'ssh' && form.sshPublicKey.trim() ? form.sshPublicKey.trim() : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ valid, buildSpec, reset, applySpec, summary })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection v-if="props.sectioned" title="规格" />
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField
|
||||
label="Shape"
|
||||
required
|
||||
hint="清单来自租户当前区域实际提供的 shape(免费与无配额已标注),可手输其他"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="form.shape"
|
||||
filterable
|
||||
tag
|
||||
:options="shapeOptions"
|
||||
:loading="shapes.loading.value || limits.loading.value"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="可用域" :hint="props.adHint ?? '留空默认 AD-1'">
|
||||
<NSelect
|
||||
v-model:value="form.availabilityDomain"
|
||||
clearable
|
||||
:options="adOptions"
|
||||
:loading="ads.loading.value"
|
||||
placeholder="自动"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div v-if="isFlex" class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="OCPU" required :hint="`该 shape 可选 ${ocpuRange.min}–${ocpuRange.max}`">
|
||||
<AppInputNumber
|
||||
v-model:value="form.ocpus"
|
||||
:min="ocpuRange.min"
|
||||
:max="ocpuRange.max"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="内存(GB)" :hint="`该 shape 可选 ${memRange.min}–${memRange.max} GB`">
|
||||
<AppInputNumber
|
||||
v-model:value="form.memoryInGBs"
|
||||
:min="memRange.min"
|
||||
:max="memRange.max"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="镜像" required hint="同名镜像只显示日期最新的一版,已按 shape 过滤">
|
||||
<NSelect
|
||||
v-model:value="form.imageId"
|
||||
filterable
|
||||
:options="imageOptions"
|
||||
:loading="images.loading.value"
|
||||
placeholder="选择启动镜像"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormSection v-if="props.sectioned" title="网络与存储" />
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="VCN" hint="「自动创建」复用或创建 oci-portal-auto-vcn 公网子网,含 IPv4/IPv6 默认路由">
|
||||
<NSelect v-model:value="form.vcnId" :options="vcnOptions" :loading="vcns.loading.value" />
|
||||
</FormField>
|
||||
<FormField v-if="form.vcnId !== AUTO_VCN" label="子网" required>
|
||||
<NSelect
|
||||
v-model:value="form.subnetId"
|
||||
:options="subnetOptions"
|
||||
:loading="subnets.loading.value"
|
||||
placeholder="选择子网"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="引导卷(GB)" hint="留空按镜像默认(约 47 GB)">
|
||||
<AppInputNumber
|
||||
v-model:value="form.bootVolumeSizeGBs"
|
||||
:min="47"
|
||||
:max="32768"
|
||||
clearable
|
||||
class="w-full"
|
||||
placeholder="默认"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="引导卷性能(VPU/GB)" hint="10 均衡 – 120 超高性能">
|
||||
<AppInputNumber
|
||||
v-model:value="form.bootVolumeVpusPerGB"
|
||||
:min="10"
|
||||
:max="120"
|
||||
:step="10"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormSection v-if="props.sectioned" title="登录与 IP" />
|
||||
<FormField
|
||||
label="登录方式"
|
||||
hint="root 密码经 cloud-init 开启 root 密码登录,随机密码生成后写入实例标签 RootPassword"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2.5 max-md:grid-cols-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-start gap-2.5 rounded-lg border bg-white px-3 py-2.5 text-left"
|
||||
:class="
|
||||
form.authMode === 'password'
|
||||
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
|
||||
: 'border-line hover:border-ink-3'
|
||||
"
|
||||
@click="form.authMode = 'password'"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-[15px] w-[15px] flex-none items-center justify-center rounded-full border-[1.5px]"
|
||||
:class="form.authMode === 'password' ? 'border-accent' : 'border-line'"
|
||||
>
|
||||
<span v-if="form.authMode === 'password'" class="h-[7px] w-[7px] rounded-full bg-accent" />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-[12.5px] font-semibold">root 密码</span>
|
||||
<span class="mt-0.5 block text-[11px] leading-snug text-ink-3">
|
||||
随机或手动设置,经 cloud-init 开启 root 登录
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-start gap-2.5 rounded-lg border bg-white px-3 py-2.5 text-left"
|
||||
:class="
|
||||
form.authMode === 'ssh'
|
||||
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
|
||||
: 'border-line hover:border-ink-3'
|
||||
"
|
||||
@click="form.authMode = 'ssh'"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-[15px] w-[15px] flex-none items-center justify-center rounded-full border-[1.5px]"
|
||||
:class="form.authMode === 'ssh' ? 'border-accent' : 'border-line'"
|
||||
>
|
||||
<span v-if="form.authMode === 'ssh'" class="h-[7px] w-[7px] rounded-full bg-accent" />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-[12.5px] font-semibold">SSH 公钥</span>
|
||||
<span class="mt-0.5 block text-[11px] leading-snug text-ink-3">
|
||||
粘贴 ssh-ed25519 / ssh-rsa 公钥,不开启密码登录
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
<template v-if="form.authMode === 'password'">
|
||||
<div class="mb-3.5">
|
||||
<NCheckbox v-model:checked="form.randomPassword">随机密码</NCheckbox>
|
||||
</div>
|
||||
<FormField v-if="!form.randomPassword" label="root 密码">
|
||||
<NInput
|
||||
v-model:value="form.rootPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="设置 root 登录密码"
|
||||
/>
|
||||
</FormField>
|
||||
</template>
|
||||
<FormField v-else label="SSH 公钥">
|
||||
<NInput
|
||||
v-model:value="form.sshPublicKey"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
class="mono"
|
||||
placeholder="ssh-ed25519 AAAA…"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="flex flex-wrap gap-5">
|
||||
<NCheckbox v-model:checked="form.assignPublicIp">分配公网 IPv4</NCheckbox>
|
||||
<NCheckbox v-model:checked="form.assignIpv6" :disabled="!ipv6Supported">
|
||||
分配 IPv6{{ ipv6Supported ? '' : '(所选子网未启用)' }}
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user