639 lines
23 KiB
Vue
639 lines
23 KiB
Vue
<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 { listReservedIps } from '@/api/reservedips'
|
||
import { freeReservedIpOptions, renderReservedIpLabel } from '@/components/network/reservedIpSelect'
|
||
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'
|
||
import type { ComputeShape, LimitItem } from '@/types/api'
|
||
|
||
/** 实例规格表单(创建实例弹窗与抢机任务共用):
|
||
* Shape / 可用域 / 规格 / 镜像 / VCN 子网 / 引导卷 / 登录方式 / IP 分配。
|
||
* 实例名、数量、租户 / 区域 / 区间由父表单提供,经 props 传入作数据源;
|
||
* sectioned 为 true 时渲染「规格 / 网络与存储 / 登录与 IP」分区标题。 */
|
||
const props = defineProps<{
|
||
cfgId: number | null
|
||
region?: string
|
||
compartmentId?: string
|
||
sectioned?: boolean
|
||
/** 可用域字段说明;抢机任务留空为轮询语义,与手动创建(固定 AD-1)不同 */
|
||
adHint?: string
|
||
/** 创建场景:AD 留空(默认 AD-1)而所选 shape 在 AD-1 无配额时,自动落位首个有配额 AD;
|
||
* 抢机表单留空 = 轮询语义,勿开 */
|
||
autoPickAd?: boolean
|
||
}>()
|
||
|
||
/** VCN 选择的「自动创建」哨兵:提交时不传 subnetId,由后端建 VCN 与子网 */
|
||
const AUTO_VCN = '__auto__'
|
||
|
||
const blank = {
|
||
availabilityDomain: null as string | null,
|
||
shape: 'VM.Standard.A1.Flex',
|
||
ocpus: 2,
|
||
memoryInGBs: 12,
|
||
imageId: null as string | null,
|
||
vcnId: AUTO_VCN,
|
||
subnetId: null as string | null,
|
||
bootVolumeSizeGBs: null as number | null,
|
||
bootVolumeVpusPerGB: 10,
|
||
assignPublicIp: true,
|
||
publicIpMode: 'ephemeral' as 'ephemeral' | 'reserved' | 'none',
|
||
reservedPublicIpId: null as string | null,
|
||
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
|
||
if (typeof s.reservedPublicIpId === 'string' && s.reservedPublicIpId) {
|
||
form.publicIpMode = 'reserved'
|
||
form.reservedPublicIpId = s.reservedPublicIpId
|
||
} else {
|
||
form.publicIpMode = s.assignPublicIp !== false ? 'ephemeral' : 'none'
|
||
}
|
||
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
|
||
},
|
||
)
|
||
|
||
/** 免费类计费类型:仅用于排序置顶,不在选项文案中标注 */
|
||
const FREE_BILLING = new Set(['ALWAYS_FREE', 'LIMITED_FREE'])
|
||
|
||
/** shape 的核数配额名:quotaNames 排除 memory / reserved / reservable 变体,
|
||
* 剩余为核数(或实例数)限额名,与 compute limits 的 name 同名 */
|
||
function coreQuotaNames(s: ComputeShape): string[] {
|
||
return (s.quotaNames ?? []).filter(
|
||
(n) => !n.includes('memory') && !n.includes('reserved') && !n.includes('reservable'),
|
||
)
|
||
}
|
||
|
||
/** 单个 shape 的配额汇总:AD 行按 AD 求和,REGION 行累计为区域总额;无匹配行返回 null */
|
||
function shapeQuotaEntry(names: Set<string>, rows: LimitItem[]): ShapeQuota | null {
|
||
const byAd = new Map<string, number>()
|
||
let regional: number | null = null
|
||
let matched = false
|
||
for (const r of rows) {
|
||
if (!names.has(r.name)) continue
|
||
matched = true
|
||
if (r.scopeType === 'AD' && r.availabilityDomain) {
|
||
byAd.set(r.availabilityDomain, (byAd.get(r.availabilityDomain) ?? 0) + Math.max(r.value, 0))
|
||
} else if (r.scopeType === 'REGION') {
|
||
regional = (regional ?? 0) + Math.max(r.value, 0)
|
||
}
|
||
}
|
||
return matched ? { byAd, regional } : null
|
||
}
|
||
|
||
interface ShapeQuota {
|
||
byAd: Map<string, number> // AD 全名 → 核数配额
|
||
regional: number | null // 区域级总额,null = 无区域行
|
||
}
|
||
|
||
/** 全部 shape 的配额汇总;quotaNames 缺失或 limits 无匹配行的 shape 无条目(不判定) */
|
||
const quotaByShape = computed(() => {
|
||
const rows = limits.data.value ?? []
|
||
const out = new Map<string, ShapeQuota>()
|
||
if (!rows.length) return out
|
||
for (const s of shapes.data.value ?? []) {
|
||
const names = new Set(coreQuotaNames(s))
|
||
if (!names.size) continue
|
||
const entry = shapeQuotaEntry(names, rows)
|
||
if (entry) out.set(s.name, entry)
|
||
}
|
||
return out
|
||
})
|
||
|
||
/** shape 在指定 AD(null = 任一 AD)是否有配额;null = 数据不足不判定 */
|
||
function quotaInAd(name: string, ad: string | null): boolean | null {
|
||
const q = quotaByShape.value.get(name)
|
||
if (!q) return null
|
||
if (q.regional !== null && q.regional <= 0) return false
|
||
if (!q.byAd.size) return true // 仅区域级限额且 > 0
|
||
if (ad === null) return [...q.byAd.values()].some((v) => v > 0)
|
||
return (q.byAd.get(ad) ?? 0) > 0
|
||
}
|
||
|
||
/** 排序:免费 shape 优先 → VM.Standard 系列 → 其他 VM → 裸金属等 */
|
||
function shapeRank(s: { billingType: string; name: string }): number {
|
||
if (FREE_BILLING.has(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 模式);
|
||
// 选定 AD 时按该 AD 判定配额,未选时看任一 AD
|
||
const list = shapes.data.value ?? []
|
||
const ad = form.availabilityDomain
|
||
return [...list]
|
||
.sort((a, b) => shapeRank(a) - shapeRank(b) || a.name.localeCompare(b.name))
|
||
.map((s) => {
|
||
const ok = quotaInAd(s.name, ad)
|
||
const suffix = ok === false ? (ad ? ' · 该 AD 无配额' : ' · 无配额') : ''
|
||
return { label: s.name + suffix, value: s.name, disabled: ok === false }
|
||
})
|
||
})
|
||
|
||
// 清单 / 配额加载后,当前 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 }))
|
||
})
|
||
|
||
/** AD 选项按当前 shape 的配额标注并禁用无配额项;数据不足时不标注 */
|
||
const adOptions = computed(
|
||
() =>
|
||
ads.data.value?.map((ad) => {
|
||
const ok = quotaInAd(form.shape, ad)
|
||
return {
|
||
label: shortAd(ad) + (ok === false ? '(无配额)' : ''),
|
||
value: ad,
|
||
disabled: ok === false,
|
||
}
|
||
}) ?? [],
|
||
)
|
||
|
||
// 创建场景(autoPickAd):AD 留空将由后端取 AD-1;所选 shape 在 AD-1 无配额
|
||
// 而其他 AD 有配额时,自动落位首个有配额 AD(字段可见,用户可改)
|
||
watch([() => form.shape, quotaByShape, () => ads.data.value], () => {
|
||
if (!props.autoPickAd || form.availabilityDomain !== null) return
|
||
const list = ads.data.value ?? []
|
||
if (!list.length || quotaInAd(form.shape, list[0]) !== false) return
|
||
const target = list.find((ad) => quotaInAd(form.shape, ad) === true)
|
||
if (target) form.availabilityDomain = target
|
||
})
|
||
|
||
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
|
||
if (form.publicIpMode === 'reserved' && !form.reservedPublicIpId) return false
|
||
return !isFlex.value || form.ocpus > 0
|
||
})
|
||
|
||
// ---- 公网 IPv4 分配方式:临时 / 保留 / 不分配 ----
|
||
const publicIpModes = [
|
||
{ label: '临时 IP', value: 'ephemeral' },
|
||
{ label: '保留 IP', value: 'reserved' },
|
||
{ label: '不分配', value: 'none' },
|
||
]
|
||
const reservedIps = useAsync(async () => {
|
||
if (props.cfgId === null) return []
|
||
try {
|
||
return await listReservedIps(props.cfgId, props.region || undefined, props.compartmentId || undefined)
|
||
} catch {
|
||
return []
|
||
}
|
||
}, false)
|
||
const freeReservedOptions = computed(() => freeReservedIpOptions(reservedIps.data.value ?? []))
|
||
watch(
|
||
() => form.publicIpMode,
|
||
(mode) => {
|
||
if (mode === 'reserved') void reservedIps.run()
|
||
else form.reservedPublicIpId = null
|
||
},
|
||
)
|
||
|
||
/** 规格摘要(父表单底部预估条用):shape 短名 + 弹性核内存 */
|
||
const summary = computed(() => {
|
||
const parts = [form.shape.replace(/^VM\.Standard\.?/, '') || form.shape]
|
||
if (isFlex.value) parts.push(`${form.ocpus} OCPU · ${form.memoryInGBs} GB`)
|
||
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.publicIpMode === 'ephemeral',
|
||
reservedPublicIpId:
|
||
form.publicIpMode === 'reserved' ? (form.reservedPublicIpId ?? undefined) : undefined,
|
||
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="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||
<FormField label="公网 IPv4" hint="保留 IP:实例就绪后自动绑定,期间短暂无公网地址;仅单台创建可用">
|
||
<NSelect v-model:value="form.publicIpMode" :options="publicIpModes" />
|
||
</FormField>
|
||
<FormField v-if="form.publicIpMode === 'reserved'" label="保留 IP" required>
|
||
<NSelect
|
||
v-model:value="form.reservedPublicIpId"
|
||
:options="freeReservedOptions"
|
||
:loading="reservedIps.loading.value"
|
||
:render-label="renderReservedIpLabel"
|
||
:consistent-menu-width="false"
|
||
placeholder="选择未绑定的保留 IP"
|
||
filterable
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<div class="flex flex-wrap gap-5">
|
||
<NCheckbox v-model:checked="form.assignIpv6" :disabled="!ipv6Supported">
|
||
分配 IPv6{{ ipv6Supported ? '' : '(所选子网未启用)' }}
|
||
</NCheckbox>
|
||
</div>
|
||
</template>
|