初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
@@ -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>