初始提交: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
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { NInput, NSelect, useMessage, type SelectOption } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { attachVnic } from '@/api/instances'
import { listSubnets } from '@/api/networks'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import ToggleCard from '@/components/ToggleCard.vue'
import { useAsync } from '@/composables/useAsync'
import { shortAd } from '@/composables/useFormat'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
/** 已附加网卡数,仅用于配额提示文案 */
attachedCount: number
}>()
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
const message = useMessage()
const submitting = ref(false)
const subnetId = ref<string | null>(null)
const displayName = ref('')
const privateIp = ref('')
const assignPublicIp = ref(false)
const assignIpv6 = ref(false)
/** vcnId 传空列全部子网:次要网卡允许跨 VCN */
const subnets = useAsync(() => listSubnets(props.cfgId, '', props.region), false)
watch(
() => props.show,
(v) => {
if (v) {
subnetId.value = null
displayName.value = ''
privateIp.value = ''
assignPublicIp.value = false
assignIpv6.value = false
void subnets.run()
}
},
)
const options = computed<SelectOption[]>(() =>
(subnets.data.value ?? [])
.filter((s) => s.lifecycleState === 'AVAILABLE')
.map((s) => ({
label: `${s.displayName}${s.cidrBlock}${s.availabilityDomain ? ` · ${shortAd(s.availabilityDomain)}` : ''}`,
value: s.id,
})),
)
const selected = computed(() => (subnets.data.value ?? []).find((s) => s.id === subnetId.value))
/** 私有子网禁分公网 IP;未启用 IPv6 的子网禁自动分配 */
const publicIpBlocked = computed(() => !!selected.value?.prohibitPublicIp)
const ipv6Blocked = computed(() => !!selected.value && !selected.value.ipv6CidrBlock)
watch(selected, (s) => {
if (s?.prohibitPublicIp) assignPublicIp.value = false
if (s && !s.ipv6CidrBlock) assignIpv6.value = false
})
async function submit() {
if (!subnetId.value) return
submitting.value = true
try {
await attachVnic(props.cfgId, props.instanceId, {
region: props.region,
subnetId: subnetId.value,
displayName: displayName.value.trim(),
privateIp: privateIp.value.trim(),
assignPublicIp: assignPublicIp.value,
assignIpv6: assignIpv6.value,
})
message.success('附加请求已提交,OS 内需自行配置新网卡 IP')
emit('update:show', false)
emit('attached')
} catch (e) {
message.error(e instanceof Error ? e.message : '附加失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="附加 VNIC"
:submitting="submitting"
:submit-disabled="!subnetId"
submit-text="附加"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="子网"
required
hint="与实例同 AD 的子网;跨 VCN 子网亦可选"
:error="
!subnets.loading.value && !options.length ? '该区域没有可用子网,请先创建' : undefined
"
>
<NSelect
v-model:value="subnetId"
:options="options"
:loading="subnets.loading.value"
filterable
placeholder="选择子网"
/>
</FormField>
<FormField label="显示名称">
<NInput v-model:value="displayName" placeholder="留空自动生成" />
</FormField>
<FormField label="私网 IP">
<NInput v-model:value="privateIp" placeholder="留空自动分配(推荐)" />
</FormField>
<div class="flex flex-col gap-2.5">
<ToggleCard
v-model="assignPublicIp"
title="分配公网 IP"
:desc="publicIpBlocked ? '所选子网为私有子网,不可分配公网 IP' : '仅公有子网可用;临时地址,分离即释放'"
:disabled="publicIpBlocked"
/>
<ToggleCard
v-model="assignIpv6"
title="自动分配 IPv6"
:desc="
ipv6Blocked
? '所选子网未启用 IPv6(可在 VCN 详情一键启用)'
: '附加时同时分一个 IPv6;后续可在网卡列表继续添加'
"
:disabled="ipv6Blocked"
/>
</div>
<div class="mt-2 rounded-md bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3">
当前已附加 {{ attachedCount }} 张网卡可附加数量由 shape 决定 A1.Flex OCPU 1
超出配额时 OCI 会拒绝请求
</div>
</FormModal>
</template>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { NCheckbox, NSelect, useMessage, type SelectGroupOption } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listBootVolumes } from '@/api/bootVolumes'
import { attachVolume, listVolumes } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
availabilityDomain: string
attachedVolumeIds: string[]
}>()
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
const message = useMessage()
const submitting = ref(false)
const volumeId = ref<string | null>(null)
const readOnly = ref(false)
const volumes = useAsync(
() => listVolumes(props.cfgId, props.availabilityDomain, undefined, props.region),
false,
)
/** 未挂载的引导卷可作为数据卷附加(OCI attachVolume 直接接受引导卷 OCID */
const bootVolumes = useAsync(
() =>
listBootVolumes(props.cfgId, props.availabilityDomain, undefined, props.region).catch(() => []),
false,
)
watch(
() => props.show,
(v) => {
if (v) {
volumeId.value = null
readOnly.value = false
void volumes.run()
void bootVolumes.run()
}
},
)
const options = computed<SelectGroupOption[]>(() => {
const vols = (volumes.data.value ?? [])
.filter((v) => v.lifecycleState === 'AVAILABLE' && !props.attachedVolumeIds.includes(v.id))
.map((v) => ({ label: `${v.displayName}${v.sizeInGBs} GB`, value: v.id }))
const bvs = (bootVolumes.data.value ?? [])
.filter(
(b) =>
b.lifecycleState === 'AVAILABLE' &&
!b.attachedInstanceId &&
!props.attachedVolumeIds.includes(b.id),
)
.map((b) => ({ label: `${b.displayName}${b.sizeInGBs} GB`, value: b.id }))
const groups: SelectGroupOption[] = []
if (vols.length) groups.push({ type: 'group', label: '块卷', key: 'vol', children: vols })
if (bvs.length) groups.push({ type: 'group', label: '引导卷(作数据卷)', key: 'bv', children: bvs })
return groups
})
const loading = computed(() => volumes.loading.value || bootVolumes.loading.value)
async function submit() {
if (!volumeId.value) return
submitting.value = true
try {
await attachVolume(props.cfgId, props.instanceId, volumeId.value, readOnly.value, props.region)
message.success('附加请求已提交,稍候在 OS 内可见新磁盘')
emit('update:show', false)
emit('attached')
} catch (e) {
message.error(e instanceof Error ? e.message : '附加失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="附加块卷"
:submitting="submitting"
:submit-disabled="!volumeId"
submit-text="附加"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="块卷 / 引导卷"
required
hint="同可用域的块卷,或未挂载的引导卷(作为数据卷附加)"
:error="!loading && !options.length ? '同可用域内没有可附加的块卷或引导卷' : undefined"
>
<NSelect
v-model:value="volumeId"
:options="options"
:loading="loading"
placeholder="选择块卷或引导卷"
/>
</FormField>
<NCheckbox v-model:checked="readOnly">只读附加</NCheckbox>
<div class="mt-2 text-xs leading-relaxed text-ink-3">
半虚拟化附加运行中实例可热插拔附加后仍需在实例 OS 内分区挂载文件系统
</div>
</FormModal>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { NInput, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { createConsoleConnection } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
const props = defineProps<{ show: boolean; cfgId: number; instanceId: string; region?: string }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
const publicKey = ref('')
watch(
() => props.show,
(v) => {
if (v) publicKey.value = ''
},
)
const isRsa = computed(() => publicKey.value.trim().startsWith('ssh-rsa '))
async function submit() {
submitting.value = true
try {
await createConsoleConnection(props.cfgId, props.instanceId, publicKey.value.trim(), props.region)
message.success('控制台连接已创建,稍候列表刷新出连接串')
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="创建控制台连接"
:width="560"
:submitting="submitting"
:submit-disabled="!isRsa"
submit-text="创建"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="SSH 公钥(仅 RSA"
required
hint="生成示例:ssh-keygen -t rsa -b 4096 -f console_key"
:error="
publicKey.trim() && !isRsa
? '必须以 ssh-rsa 开头 —— OCI 控制台连接不接受 ed25519 等其他算法'
: undefined
"
>
<NInput
v-model:value="publicKey"
type="textarea"
:rows="4"
class="mono"
placeholder="ssh-rsa AAAAB3…"
/>
</FormField>
<div class="text-xs leading-relaxed text-ink-3">
创建后用对应私钥执行连接串串口直接执行 connectionStringVNC 执行 vncConnectionString
建立隧道后 VNC 客户端连 localhost:5900
</div>
</FormModal>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { NInput, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createInstances } from '@/api/instances'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import InstanceSpecForm from '@/components/instance/InstanceSpecForm.vue'
import { defaultResourceName } from '@/composables/useFormat'
/** 租户 / 区间 / 区域直接使用父页面当前值,不在表单中出现 */
const props = defineProps<{
show: boolean
cfgId: number | null
compartmentId?: string
region?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
const specForm = ref<InstanceType<typeof InstanceSpecForm> | null>(null)
const form = reactive({ displayName: '', count: 1 })
watch(
() => props.show,
(v) => {
if (!v) return
form.displayName = defaultResourceName('instance')
form.count = 1
specForm.value?.reset()
},
)
const canSubmit = computed(
() =>
props.cfgId !== null &&
Boolean(form.displayName.trim()) &&
(specForm.value?.valid ?? false),
)
async function submit() {
if (props.cfgId === null || !specForm.value) return
submitting.value = true
try {
const result = await createInstances(props.cfgId, {
region: props.region || undefined,
compartmentId: props.compartmentId || undefined,
displayName: form.displayName.trim(),
count: form.count,
...specForm.value.buildSpec(),
})
// 后端 errors / instances 为空时序列化为 null,判空防炸
const ok = result.instances?.length ?? 0
const failed = result.errors?.length ?? 0
if (failed) message.warning(`${ok} 台创建成功,${failed} 台失败:${result.errors![0]}`)
else message.success(`已创建 ${ok} 台实例`)
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="创建实例"
:width="600"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="创建"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="实例名" required>
<NInput v-model:value="form.displayName" placeholder="instance-20260703-2136" />
</FormField>
<FormField label="数量" hint="批量创建,名称自动追加序号">
<AppInputNumber v-model:value="form.count" :min="1" :max="10" class="w-full" />
</FormField>
</div>
<InstanceSpecForm
ref="specForm"
sectioned
:cfg-id="cfgId"
:region="region"
:compartment-id="compartmentId"
/>
<div
class="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3"
>
<span v-if="specForm?.summary" class="font-medium text-ink-2">
预估规格:{{ form.count }} · {{ specForm.summary }}
</span>
<span>容量不足Out of host capacity时可改用抢机任务反复尝试</span>
</div>
</FormModal>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import AppInputNumber from '@/components/AppInputNumber.vue'
import { reactive, ref, watch } from 'vue'
import { updateBootVolume } from '@/api/bootVolumes'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import type { BootVolume } from '@/types/api'
const props = defineProps<{
show: boolean
cfgId: number
region?: string
bootVolume: BootVolume | null
}>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
const message = useMessage()
const submitting = ref(false)
const form = reactive({ sizeInGBs: 50, vpusPerGB: 10 })
watch(
() => props.show,
(v) => {
if (!v || !props.bootVolume) return
form.sizeInGBs = props.bootVolume.sizeInGBs
form.vpusPerGB = props.bootVolume.vpusPerGB
},
)
async function submit() {
const bv = props.bootVolume
if (!bv) return
if (form.sizeInGBs < bv.sizeInGBs) {
message.error('引导卷仅支持扩容,不能缩小')
return
}
submitting.value = true
try {
await updateBootVolume(
props.cfgId,
bv.id,
{
sizeInGBs: form.sizeInGBs === bv.sizeInGBs ? 0 : form.sizeInGBs,
vpusPerGB: form.vpusPerGB === bv.vpusPerGB ? 0 : form.vpusPerGB,
},
props.region,
)
message.success('修改已提交;扩容后需在实例 OS 内扩展文件系统')
emit('update:show', false)
emit('updated')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="调整引导卷"
:submitting="submitting"
submit-text="保存"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="大小(GB" hint="仅支持扩容;扩容后需在 OS 内扩展文件系统">
<AppInputNumber
v-model:value="form.sizeInGBs"
:min="bootVolume?.sizeInGBs ?? 47"
:max="32768"
class="w-full"
/>
</FormField>
<FormField label="性能(VPU/GB" hint="10 均衡 120 超高性能,可在线调整">
<AppInputNumber v-model:value="form.vpusPerGB" :min="10" :max="120" :step="10" class="w-full" />
</FormField>
</div>
</FormModal>
</template>
@@ -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>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { NSelect, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import VChart from 'vue-echarts'
import { getInstanceTraffic } from '@/api/analytics'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import { fmtBytes } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useAppStore } from '@/stores/app'
import { darkTokens, tokens } from '@/theme/tokens'
use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
/** echarts 不认 CSS 变量,色板按明暗主题从 tokens 取 */
const app = useAppStore()
const t = computed(() => (app.dark ? darkTokens : tokens))
const props = defineProps<{ cfgId: number; instanceId: string; region?: string }>()
const days = ref(7)
const traffic = useAsync(() =>
getInstanceTraffic(props.cfgId, props.instanceId, days.value, props.region),
)
watch(days, () => void traffic.run())
const daysOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
]
const series = computed(() => {
const vnic = traffic.data.value?.vnics[0]
if (!vnic) return null
const labels = vnic.outbound.map((p) => p.timestamp.slice(5, 10))
return {
labels,
outbound: vnic.outbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
inbound: vnic.inbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
}
})
const option = computed(() => ({
grid: { left: 44, right: 16, top: 36, bottom: 26 },
legend: { top: 4, left: 8, textStyle: { color: t.value.ink2, fontSize: 12 }, itemWidth: 14 },
tooltip: {
trigger: 'axis',
backgroundColor: t.value.ink,
borderWidth: 0,
textStyle: { color: t.value.bg, fontSize: 12 },
valueFormatter: (v: number) => `${v} GB`,
},
xAxis: {
type: 'category',
data: series.value?.labels ?? [],
boundaryGap: false,
axisLine: { lineStyle: { color: t.value.line } },
axisTick: { show: false },
axisLabel: { color: t.value.ink3, fontSize: 10.5 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: t.value.lineSoft } },
axisLabel: { color: t.value.ink3, fontSize: 10.5, formatter: '{value}G' },
},
series: [
{
name: '流出',
type: 'line',
data: series.value?.outbound ?? [],
lineStyle: { color: t.value.chart, width: 2 },
itemStyle: { color: t.value.chart },
symbol: 'circle',
symbolSize: 5,
},
{
name: '流入',
type: 'line',
data: series.value?.inbound ?? [],
lineStyle: { color: t.value.info, width: 2 },
itemStyle: { color: t.value.info },
symbol: 'circle',
symbolSize: 5,
},
],
}))
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">流量</div>
<div class="flex-1" />
<div class="w-28">
<NSelect
v-model:value="days"
size="small"
:options="daysOptions"
:loading="traffic.loading.value"
/>
</div>
</div>
<div
v-if="traffic.loading.value && !traffic.data.value"
class="flex items-center justify-center py-14"
>
<NSpin size="small" />
</div>
<template v-else-if="traffic.data.value">
<div class="flex flex-wrap gap-5 px-4.5 pt-3 text-[12.5px] text-ink-2">
<span>
<span class="mr-1.5 inline-block h-2 w-2 rounded-full" style="background: var(--color-chart)" />
流出 · 合计 {{ fmtBytes(traffic.data.value.outboundBytes) }}
</span>
<span>
<span class="mr-1.5 inline-block h-2 w-2 rounded-full" style="background: var(--color-info)" />
流入 · 合计 {{ fmtBytes(traffic.data.value.inboundBytes) }}
</span>
</div>
<div class="px-3 pb-2">
<VChart class="w-full" style="height: 210px" :option="option" autoresize />
</div>
</template>
<EmptyCard
v-else
title="暂无流量数据"
note="该实例暂无 Monitoring 数据点(新建实例有数分钟延迟,终止实例不再产生数据)"
/>
<FootNote>
数据来自 Monitoring 指标VnicToNetworkBytes 发送 / VnicFromNetworkBytes 接收按日聚合
</FootNote>
</div>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { NSelect, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listBootVolumes } from '@/api/bootVolumes'
import { replaceBootVolume } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
availabilityDomain: string
}>()
const emit = defineEmits<{ 'update:show': [boolean]; replaced: [] }>()
const message = useMessage()
const submitting = ref(false)
const bootVolumeId = ref<string | null>(null)
const volumes = useAsync(
() => listBootVolumes(props.cfgId, props.availabilityDomain, undefined, props.region),
false,
)
watch(
() => props.show,
(v) => {
if (v) {
bootVolumeId.value = null
void volumes.run()
}
},
)
const options = computed(
() =>
volumes.data.value
?.filter((v) => !v.attachedInstanceId)
.map((v) => ({ label: `${v.displayName}${v.sizeInGBs} GB`, value: v.id })) ?? [],
)
async function submit() {
if (!bootVolumeId.value) return
submitting.value = true
try {
await replaceBootVolume(props.cfgId, props.instanceId, bootVolumeId.value, props.region)
message.success('替换请求已提交:分离旧引导卷并挂载所选引导卷')
emit('update:show', false)
emit('replaced')
} catch (e) {
message.error(e instanceof Error ? e.message : '替换失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="替换引导卷"
:submitting="submitting"
:submit-disabled="!bootVolumeId"
submit-text="替换"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField
label="目标引导卷"
required
hint="仅列出同可用域、未挂载的引导卷"
:error="
!volumes.loading.value && !options.length
? '同可用域内没有未挂载的引导卷,无法替换'
: undefined
"
>
<NSelect
v-model:value="bootVolumeId"
:options="options"
:loading="volumes.loading.value"
placeholder="选择未挂载的引导卷"
/>
</FormField>
<div class="text-xs leading-relaxed text-ink-3">
原引导卷分离后保留不会删除实例保持 STOPPED替换完成后可启动
</div>
</FormModal>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { NSelect, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { listShapes, updateInstance } from '@/api/instances'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
shape: string
ocpus: number
memoryInGBs: number
}>()
const emit = defineEmits<{ 'update:show': [boolean]; resized: [] }>()
const message = useMessage()
const submitting = ref(false)
const form = reactive({ shape: props.shape, ocpus: props.ocpus, memoryInGBs: props.memoryInGBs })
const shapes = useAsync(() => listShapes(props.cfgId, props.region).catch(() => []), false)
watch(
() => props.show,
(v) => {
if (v) {
Object.assign(form, { shape: props.shape, ocpus: props.ocpus, memoryInGBs: props.memoryInGBs })
void shapes.run()
}
},
)
const BILLING_LABEL: Record<string, string> = {
ALWAYS_FREE: '永久免费',
LIMITED_FREE: '免费额度',
}
const shapeOptions = computed(() => {
const list = shapes.data.value ?? []
if (!list.length) return [{ label: form.shape, value: form.shape }]
return list.map((s) => {
const free = BILLING_LABEL[s.billingType]
return { label: s.name + (free ? `${free}` : ''), value: s.name }
})
})
const selectedShape = computed(
() => shapes.data.value?.find((s) => s.name === form.shape) ?? null,
)
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)
})
const changed = computed(
() =>
form.shape !== props.shape ||
(isFlex.value && (form.ocpus !== props.ocpus || form.memoryInGBs !== props.memoryInGBs)),
)
async function submit() {
submitting.value = true
try {
await updateInstance(
props.cfgId,
props.instanceId,
{
shape: form.shape !== props.shape ? form.shape : undefined,
ocpus: isFlex.value ? form.ocpus : undefined,
memoryInGBs: isFlex.value ? form.memoryInGBs : undefined,
},
props.region,
)
message.success('规格调整已提交,运行中实例将自动重启')
emit('update:show', false)
emit('resized')
} catch (e) {
message.error(e instanceof Error ? e.message : '调整失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="调整规格"
:submitting="submitting"
:submit-disabled="!changed"
submit-text="提交调整"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField label="Shape" required hint="可更换为租户当前区域提供的其他 shape,需目标容量可用">
<NSelect
v-model:value="form.shape"
filterable
tag
:options="shapeOptions"
:loading="shapes.loading.value"
/>
</FormField>
<div v-if="isFlex" class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="OCPU" required :hint="`可选 ${ocpuRange.min}${ocpuRange.max}`">
<AppInputNumber
v-model:value="form.ocpus"
:min="ocpuRange.min"
:max="ocpuRange.max"
class="w-full"
/>
</FormField>
<FormField label="内存(GB" required :hint="`可选 ${memRange.min}${memRange.max} GB`">
<AppInputNumber
v-model:value="form.memoryInGBs"
:min="memRange.min"
:max="memRange.max"
class="w-full"
/>
</FormField>
</div>
<div class="text-xs leading-relaxed text-ink-3">
运行中实例 OCI 会自动重启完成变更更换 shape 要求镜像与目标 shape 架构兼容 aarch64
镜像不能换到 x86 shapeA1 免费额度上限 4 OCPU / 24 GB
</div>
</FormModal>
</template>
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { NSpin, useMessage } from 'naive-ui'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
show: boolean
cfgId: number
instanceId: string
region?: string
instanceName?: string
rootPassword?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
const message = useMessage()
const auth = useAuthStore()
const termEl = ref<HTMLDivElement | null>(null)
const phase = ref<'idle' | 'creating' | 'connecting' | 'connected' | 'closed'>('idle')
const error = ref('')
const minimized = ref(false)
const fullscreen = ref(false)
const height = ref(360)
let term: Terminal | null = null
let fit: FitAddon | null = null
let ws: WebSocket | null = null
let ro: ResizeObserver | null = null
let sessionId = ''
const encoder = new TextEncoder()
async function open() {
phase.value = 'creating'
error.value = ''
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
try {
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
sessionId = res.sessionId
} catch (e) {
phase.value = 'closed'
error.value = e instanceof Error ? e.message : '创建串行会话失败'
return
}
phase.value = 'connecting'
await nextTick()
mountTerm()
connectWs()
}
/** 深色终端主题固定不随明暗切换(对齐 Cloud Shell 观感) */
function newTerminal(): Terminal {
return new Terminal({
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
scrollback: 5000,
theme: {
background: '#1c1b1a',
foreground: '#eceae4',
cursor: '#d97757',
selectionBackground: 'rgba(217, 119, 87, 0.35)',
},
})
}
function mountTerm() {
if (term || !termEl.value) return
term = newTerminal()
fit = new FitAddon()
term.loadAddon(fit)
term.open(termEl.value)
fit.fit()
term.onData((d) => wsSendText(d))
term.onResize(({ cols, rows }) => wsSendCtl({ type: 'resize', cols, rows }))
// 选中即复制:终端里 Ctrl+C 保留 SIGINT 语义,复制交给选区
term.onSelectionChange(() => {
const sel = term?.getSelection()
if (sel) void navigator.clipboard.writeText(sel).catch(() => undefined)
})
term.attachCustomKeyEventHandler(copyKeyHandler)
ro = new ResizeObserver(() => fit?.fit())
ro.observe(termEl.value)
}
/** Cmd+C / Ctrl+Shift+C 在有选区时作复制,其余按键交还终端 */
function copyKeyHandler(ev: KeyboardEvent): boolean {
if (ev.type !== 'keydown') return true
const combo = (ev.metaKey && !ev.ctrlKey) || (ev.ctrlKey && ev.shiftKey)
if (combo && ev.key.toLowerCase() === 'c' && term?.hasSelection()) {
void navigator.clipboard.writeText(term.getSelection()).catch(() => undefined)
return false
}
return true
}
function connectWs() {
if (!term) return
const url = consoleSessionWsUrl(sessionId, auth.token, `rows=${term.rows}&cols=${term.cols}`)
ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
phase.value = 'connected'
term?.focus()
}
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) term?.write(new Uint8Array(ev.data))
}
ws.onclose = (ev) => {
if (phase.value === 'idle' || phase.value === 'closed') return
phase.value = 'closed'
if (!error.value) error.value = mapCloseReason(ev.reason)
}
}
function mapCloseReason(reason: string): string {
if (!reason) return '连接已断开'
if (reason.includes('administratively prohibited'))
return '串行控制台已被占用:同一时刻仅允许一路连接(可能有本地 ssh 或其他会话)'
return reason
}
function wsSendText(data: string) {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data))
}
function wsSendCtl(ctl: { type: string; cols: number; rows: number }) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(ctl))
}
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText()
if (text) term?.paste(text)
} catch {
message.warning('无法读取剪贴板,请直接按 Cmd/Ctrl+V 粘贴')
}
term?.focus()
}
/** 串口 tty 收不到 SIGWINCH,向 shell 注入 stty 同步行列数(需处于提示符) */
function syncWinsize() {
if (!term) return
wsSendText(`stty rows ${term.rows} cols ${term.cols}\r`)
message.info('已发送 stty 同步命令(需处于 shell 提示符)')
term.focus()
}
async function copyRootPassword() {
await navigator.clipboard.writeText(props.rootPassword ?? '')
message.success('已复制 root 密码')
term?.focus()
}
function reconnect() {
teardown()
void open()
}
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
minimized.value = false
void nextTick(() => fit?.fit())
}
function toggleMinimize() {
minimized.value = !minimized.value
if (!minimized.value) void nextTick(() => fit?.fit())
}
function startDrag(e: MouseEvent) {
const startY = e.clientY
const startH = height.value
const move = (ev: MouseEvent) => {
height.value = Math.min(Math.max(startH + (startY - ev.clientY), 200), window.innerHeight - 72)
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
e.preventDefault()
}
function teardown() {
ro?.disconnect()
ro = null
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
if (sessionId) {
void deleteConsoleSession(sessionId).catch(() => undefined)
sessionId = ''
}
term?.dispose()
term = null
fit = null
phase.value = 'idle'
error.value = ''
}
function close() {
teardown()
minimized.value = false
fullscreen.value = false
emit('update:show', false)
}
watch(
() => props.show,
(v) => {
if (v) void open()
else teardown()
},
)
onBeforeUnmount(teardown)
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-x-0 bottom-0 z-[1500] flex flex-col border-t border-[#3a3835] shadow-[0_-6px_24px_rgba(0,0,0,0.35)]"
:class="fullscreen && !minimized ? 'top-0' : ''"
>
<div
v-if="!fullscreen && !minimized"
class="h-[3px] shrink-0 cursor-ns-resize bg-[#141413] transition-colors hover:bg-[#d97757]"
@mousedown="startDrag"
/>
<!-- 标题栏Cloud Shell 风格 -->
<div class="flex h-9 shrink-0 items-center gap-2 bg-[#141413] px-3 text-[12.5px] text-[#a8a69e]">
<span
class="inline-block h-2 w-2 rounded-full"
:style="{ background: phase === 'connected' ? '#8fa574' : phase === 'closed' ? '#d06060' : '#c29135' }"
/>
<span class="truncate font-medium text-[#eceae4]">
{{ instanceName || instanceId }} · 串行控制台
</span>
<span v-if="phase === 'creating'" class="text-xs">准备连接中</span>
<div class="flex-1" />
<button v-if="rootPassword" class="ser-btn" @click="copyRootPassword">复制 root 密码</button>
<button class="ser-btn" :disabled="phase !== 'connected'" @click="pasteFromClipboard">
粘贴
</button>
<button
class="ser-btn"
:disabled="phase !== 'connected'"
title="向 shell 发送 stty 命令同步终端行列数"
@click="syncWinsize"
>
同步窗口
</button>
<button v-if="phase === 'closed'" class="ser-btn text-[#d97757]" @click="reconnect">
重连
</button>
<div class="mx-1 h-4 w-px bg-[#3a3835]" />
<button class="ser-btn" :title="minimized ? '还原' : '最小化'" @click="toggleMinimize">
{{ minimized ? '' : '' }}
</button>
<button class="ser-btn" :title="fullscreen ? '退出全屏' : '全屏'" @click="toggleFullscreen">
</button>
<button class="ser-btn" title="关闭并结束会话" @click="close"></button>
</div>
<!-- 终端区 -->
<div
v-show="!minimized"
class="relative min-h-0 bg-[#1c1b1a]"
:class="fullscreen ? 'flex-1' : ''"
:style="fullscreen ? {} : { height: height + 'px' }"
>
<div ref="termEl" class="absolute inset-0 py-1 pl-2" />
<div
v-if="phase !== 'connected'"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#1c1b1a]/92 text-[13px] text-[#a8a69e]"
>
<template v-if="phase === 'creating' || phase === 'connecting'">
<NSpin size="small" />
<div>
{{
phase === 'creating'
? '正在清理旧连接并创建控制台连接(约 30-90 秒)…'
: '正在建立串行隧道…'
}}
</div>
</template>
<template v-else-if="phase === 'closed'">
<div class="max-w-[80%] break-all text-center">{{ error || '连接已关闭' }}</div>
<div class="max-w-[80%] text-center text-xs leading-relaxed">
串口登录需实例本地账户密码SSH 公钥无效创建实例时若启用随机 root
密码可从顶栏复制无密码也可查看启动日志或经 GRUB 单用户模式救援
</div>
<button class="ser-btn !border !border-[#3a3835] !px-3 !py-1" @click="reconnect">
重新连接
</button>
</template>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.ser-btn {
border-radius: 4px;
padding: 2px 7px;
font-size: 12px;
line-height: 1.4;
color: inherit;
transition: background-color 0.15s;
}
.ser-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #eceae4;
}
.ser-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { NButton, NPopconfirm, NSpin, useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { addVnicIpv6, detachVnic, listVnics, removeIpv6 } from '@/api/instances'
import FootNote from '@/components/FootNote.vue'
import AttachVnicModal from '@/components/instance/AttachVnicModal.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
import OcidText from '@/components/OcidText.vue'
import { useAsync } from '@/composables/useAsync'
import { ATTACHMENT_TRANSIENT_STATES, useTransientPoll } from '@/composables/useTransientPoll'
/** 网卡(VNIC)区块:每卡一主行 + IPv6 副行;IPv6 增删按卡操作。
* 删除复用实例级接口(后端按地址遍历全部网卡),添加走 per-VNIC 接口。 */
const props = defineProps<{
cfgId: number
instanceId: string
region?: string
/** 实例已终止 / 终止中:全部变更操作不可用 */
disabled?: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
const vnics = useAsync(() => listVnics(props.cfgId, props.instanceId, props.region))
useTransientPoll(
() => vnics.data.value,
(list) => !!list?.some((v) => ATTACHMENT_TRANSIENT_STATES.includes(v.lifecycleState)),
() => void vnics.run({ silent: true }),
)
const showAttach = ref(false)
const detaching = ref('')
const addingIpv6 = ref('')
const removingIpv6 = ref('')
const busy = computed(() => !!detaching.value || !!addingIpv6.value || !!removingIpv6.value)
function refresh() {
void vnics.run()
emit('changed')
}
async function doDetach(attachmentId: string) {
detaching.value = attachmentId
try {
await detachVnic(props.cfgId, attachmentId, props.region)
message.success('分离请求已提交')
refresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '分离失败')
} finally {
detaching.value = ''
}
}
async function doAddIpv6(vnicId: string) {
addingIpv6.value = vnicId
try {
await addVnicIpv6(props.cfgId, vnicId, '', props.region)
message.success('IPv6 已添加')
refresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败,请确认子网已启用 IPv6')
} finally {
addingIpv6.value = ''
}
}
async function doRemoveIpv6(addr: string) {
removingIpv6.value = addr
try {
await removeIpv6(props.cfgId, props.instanceId, addr, props.region)
message.success('IPv6 已取消分配')
refresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
removingIpv6.value = ''
}
}
defineExpose({ refresh: () => vnics.run() })
</script>
<template>
<div class="panel">
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5">
<div class="min-w-0">
<div class="text-sm font-semibold">网卡VNIC</div>
<div class="mt-0.5 text-xs text-ink-3">
实例的全部虚拟网卡每张网卡可各自附加多个 IPv6
</div>
</div>
<NButton size="small" type="primary" :disabled="disabled" @click="showAttach = true">
附加 VNIC
</NButton>
</div>
<div
v-if="vnics.loading.value && !vnics.data.value"
class="flex items-center justify-center py-8"
>
<NSpin size="small" />
</div>
<div v-else class="overflow-x-auto">
<table class="w-full min-w-[760px] border-collapse text-[13px]">
<thead>
<tr class="text-left text-xs text-ink-3">
<th class="w-[26%] border-b border-line-soft py-2 pr-3 pl-4.5 font-medium">名称</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">私网 IP</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">公网 IP</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">子网</th>
<th class="w-28 border-b border-line-soft px-3 py-2 font-medium">状态</th>
<th class="w-18 border-b border-line-soft py-2 pr-4.5 pl-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<template v-for="v in vnics.data.value ?? []" :key="v.attachmentId">
<tr>
<td class="py-2.5 pr-3 pb-1.5 pl-4.5">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-medium" :title="v.displayName">
{{ v.displayName || '(未命名)' }}
</span>
<span
v-if="v.isPrimary"
class="flex-none rounded bg-accent/10 px-1.5 py-px text-[10.5px] font-semibold text-accent"
>
主网卡
</span>
</div>
</td>
<td class="mono px-3 py-2.5 pb-1.5">{{ v.privateIp || '—' }}</td>
<td class="mono px-3 py-2.5 pb-1.5">{{ v.publicIp || '—' }}</td>
<td class="px-3 py-2.5 pb-1.5">
<span v-if="v.subnetName" class="truncate" :title="v.subnetName">
{{ v.subnetName }}
</span>
<OcidText v-else-if="v.subnetId" :ocid="v.subnetId" />
<span v-else></span>
</td>
<td class="px-3 py-2.5 pb-1.5"><LifecycleBadge :state="v.lifecycleState" /></td>
<td class="py-2.5 pr-4.5 pb-1.5 pl-3">
<NButton
v-if="v.isPrimary"
size="tiny"
quaternary
disabled
title="主网卡不可分离"
>
分离
</NButton>
<NPopconfirm v-else @positive-click="doDetach(v.attachmentId)">
<template #trigger>
<NButton
size="tiny"
quaternary
type="error"
:loading="detaching === v.attachmentId"
:disabled="disabled || v.lifecycleState !== 'ATTACHED' || busy"
>
分离
</NButton>
</template>
分离该网卡其上全部 IP IPv6将一并释放
</NPopconfirm>
</td>
</tr>
<tr class="border-b border-line-soft last:border-b-0">
<td colspan="6" class="px-4.5 pt-0 pb-2.5">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[10.5px] font-medium tracking-wider text-ink-3">IPV6</span>
<template v-if="v.vnicId">
<span
v-for="addr in v.ipv6Addresses ?? []"
:key="addr"
class="mono inline-flex max-w-full items-center gap-1 rounded border border-line bg-white px-1.5 py-0.5 text-xs"
>
<span class="truncate">{{ addr }}</span>
<button
class="flex-none cursor-pointer text-ink-3 hover:text-err disabled:cursor-wait"
:aria-label="`删除 ${addr}`"
:disabled="disabled || busy"
@click="doRemoveIpv6(addr)"
>
{{ removingIpv6 === addr ? '…' : '×' }}
</button>
</span>
<span v-if="!(v.ipv6Addresses ?? []).length" class="text-xs text-ink-3">
</span>
<NButton
size="tiny"
quaternary
type="primary"
:loading="addingIpv6 === v.vnicId"
:disabled="disabled || busy"
@click="doAddIpv6(v.vnicId)"
>
+ IPv6
</NButton>
</template>
<span v-else class="text-xs text-ink-3"> · 附加完成后可添加</span>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<div
v-if="!(vnics.data.value ?? []).length && !vnics.loading.value"
class="py-6 text-center text-[13px] text-ink-3"
>
{{ vnics.error.value || '无网卡记录' }}
</div>
</div>
<FootNote>
附加须实例运行中且 shape 有空余 VNIC 配额 · 主网卡不可分离 · 每卡 IPv6
可多个要求所在子网已启用 IPv6· 次要网卡在 OS 内需自行配置 IPdhclient / netplan
</FootNote>
<AttachVnicModal
v-model:show="showAttach"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
:attached-count="(vnics.data.value ?? []).length"
@attached="refresh"
/>
</div>
</template>