新增对象存储/账单/保留IP页面,成本今天视图与样式收敛
CI / test (push) Successful in 42s

This commit is contained in:
2026-07-21 12:11:22 +08:00
parent d813225ac4
commit ac457282ce
91 changed files with 6672 additions and 652 deletions
+4 -7
View File
@@ -8,6 +8,7 @@ import {
} from '@/api/aigateway'
import AiChannelPanel from '@/components/ai/AiChannelPanel.vue'
import AiKeyPanel from '@/components/ai/AiKeyPanel.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
@@ -95,7 +96,7 @@ function refreshChannels() {
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3 text-sm font-semibold">接入信息</div>
<PanelHeader title="接入信息" />
<div class="px-4.5 py-2">
<div class="flex items-center gap-2 border-b border-line-soft py-2 text-[13px]">
<span class="w-13 flex-none text-[10.5px] font-bold text-ok">BASE</span>
@@ -118,13 +119,9 @@ function refreshChannels() {
</div>
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3">
<div>
<div class="text-sm font-semibold">可用模型</div>
<div class="mt-0.5 text-xs text-ink-3">按厂商分组;点击厂商展开,点击模型名复制</div>
</div>
<PanelHeader title="可用模型" desc="按厂商分组;点击厂商展开,点击模型名复制">
<span class="text-xs text-ink-3">{{ models.data.value?.length ?? '…' }} </span>
</div>
</PanelHeader>
<div class="px-4.5 py-1.5">
<div
v-for="g in modelGroups"
+12 -8
View File
@@ -5,14 +5,14 @@ import {
NIcon,
NInputGroup,
NInputGroupLabel,
NPopconfirm,
NSelect,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { deleteBootVolume, listBootVolumes } from '@/api/bootVolumes'
import CompartmentSelect from '@/components/CompartmentSelect.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
import FootNote from '@/components/FootNote.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
@@ -152,12 +152,16 @@ function renderActions(row: BvRow) {
{ default: () => '删除' },
)
: h(
NPopconfirm,
{ onPositiveClick: () => remove(row) },
ConfirmPop,
{
title: '删除该引导卷?',
content: '数据不可恢复。',
confirmText: '删除',
onConfirm: () => remove(row),
},
{
trigger: () =>
h(NButton, { size: 'tiny', type: 'error', ghost: true }, { default: () => '删除' }),
default: () => '删除该引导卷?数据不可恢复',
},
)
return h('div', { class: 'flex items-center gap-1.5' }, [
@@ -255,12 +259,12 @@ const columns = computed<DataTableColumns<BvRow>>(() => [
<div class="flex flex-wrap items-center gap-2 px-4.5 py-3">
<NInputGroup class="!w-72 max-md:!w-full">
<NInputGroupLabel size="small">区间</NInputGroupLabel>
<NSelect
<CompartmentSelect
v-model:value="scope.compartmentId"
:options="scope.compOptions"
:compartments="scope.compartments.data ?? []"
:root-label="scope.rootLabel"
:loading="scope.compartments.loading"
:disabled="scope.compDisabled"
size="small"
/>
</NInputGroup>
</div>
+24 -42
View File
@@ -3,7 +3,6 @@ import {
NButton,
NDataTable,
NDropdown,
NPopconfirm,
NSpin,
useDialog,
type DataTableColumns,
@@ -14,7 +13,6 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { getBootVolume } from '@/api/bootVolumes'
import { getConfig } from '@/api/configs'
import {
changePublicIp,
deleteConsoleConnection,
detachBootVolume,
detachVolume,
@@ -27,6 +25,7 @@ import {
terminateInstance,
updateInstance,
} from '@/api/instances'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import AttachVolumeModal from '@/components/instance/AttachVolumeModal.vue'
import ConsoleConnModal from '@/components/instance/ConsoleConnModal.vue'
@@ -36,6 +35,7 @@ import ReplaceBootVolumeModal from '@/components/instance/ReplaceBootVolumeModal
import ResizeModal from '@/components/instance/ResizeModal.vue'
import VnicPanel from '@/components/instance/VnicPanel.vue'
import { useConsoleStore } from '@/stores/console'
import ConfirmPop from '@/components/ConfirmPop.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
import OcidText from '@/components/OcidText.vue'
import RenameModal from '@/components/RenameModal.vue'
@@ -221,9 +221,6 @@ async function act(fn: () => Promise<unknown>, ok: string) {
}
}
// 换 IP 请求期间的按钮 loading,操作发出立即有反馈
const changeIpBusy = ref(false)
function power(action: PowerAction) {
void act(
() => instanceAction(cfgId.value, instanceId.value, action, region.value),
@@ -255,12 +252,6 @@ function terminate() {
})
}
async function doChangeIp() {
changeIpBusy.value = true
await act(() => changePublicIp(cfgId.value, instanceId.value, region.value), '公网 IP 已更换')
changeIpBusy.value = false
}
const volColumns: DataTableColumns<VolumeAttachment> = [
{
title: '块卷',
@@ -302,14 +293,15 @@ const volColumns: DataTableColumns<VolumeAttachment> = [
width: 90,
render: (r) =>
h(
NPopconfirm,
ConfirmPop,
{
onPositiveClick: () =>
act(() => detachVolume(cfgId.value, r.id, region.value), '分离请求已提交'),
title: '分离该块卷?',
content: 'OS 内请先卸载文件系统。',
confirmText: '分离',
onConfirm: () => act(() => detachVolume(cfgId.value, r.id, region.value), '分离请求已提交'),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '分离' }),
default: () => '分离该块卷?OS 内请先卸载文件系统',
},
),
},
@@ -378,8 +370,12 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
width: 90,
render: (r) =>
h(
NPopconfirm,
{ onPositiveClick: () => doDeleteConsoleConn(r.id) },
ConfirmPop,
{
title: '删除控制台连接?',
confirmText: '删除',
onConfirm: () => doDeleteConsoleConn(r.id),
},
{
trigger: () =>
h(
@@ -393,7 +389,6 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
},
{ default: () => '删除' },
),
default: () => '删除该控制台连接?',
},
),
},
@@ -408,11 +403,8 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
>
<NSpin size="small" />
</div>
<div
v-else-if="instance.error.value"
class="panel px-5 py-10 text-center text-[13px] text-ink-3"
>
{{ instance.error.value }}
<div v-else-if="instance.error.value" class="panel">
<EmptyCard title="实例加载失败" :note="instance.error.value" />
</div>
<div v-if="instance.data.value" class="panel px-5 py-4">
@@ -562,21 +554,9 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">公网 IP</div>
<div class="min-w-0 flex-1">
<span class="mono">{{ instance.data.value.publicIp || '—' }}</span>
<NPopconfirm @positive-click="doChangeIp">
<template #trigger>
<NButton
size="tiny"
quaternary
type="primary"
:disabled="isEnded"
:loading="changeIpBusy"
>
更换
</NButton>
</template>
更换即删除旧临时公网 IP 再分配新的保留 IP 会被拒绝确认
</NPopconfirm>
<div class="mt-0.5 text-xs text-ink-3">临时 IP · 更换即删旧分新</div>
<div class="mt-0.5 text-xs text-ink-3">
IP 在下方网卡(VNIC)对应行操作
</div>
</div>
</div>
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
@@ -666,14 +646,16 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
>
调整大小 / VPU
</NButton>
<NPopconfirm
@positive-click="act(() => detachBootVolume(cfgId, att.id, region), '分离请求已提交')"
<ConfirmPop
title="分离引导卷?"
content="实例将无法启动,直到挂载新引导卷。"
confirm-text="分离"
:on-confirm="() => act(() => detachBootVolume(cfgId, att.id, region), '分离请求已提交')"
>
<template #trigger>
<NButton size="tiny" quaternary :disabled="!isStopped || isEnded">分离</NButton>
</template>
分离引导卷?实例将无法启动直到挂载新引导卷
</NPopconfirm>
</ConfirmPop>
</div>
<div
v-if="!(bvAtts.data.value ?? []).length"
+36 -6
View File
@@ -5,13 +5,14 @@ import {
NInput,
NInputGroup,
NInputGroupLabel,
NSelect,
useDialog,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { instanceAction, listInstances } from '@/api/instances'
import { instanceAction, listInstances, terminateInstance } from '@/api/instances'
import CompartmentSelect from '@/components/CompartmentSelect.vue'
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
import CreateInstanceModal from '@/components/instance/CreateInstanceModal.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
@@ -90,6 +91,30 @@ async function power(row: InstanceRow, action: PowerAction) {
}
}
const dialog = useDialog()
/** 与实例详情页一致:双选是否保留引导卷,返回 Promise 让 dialog 按钮 loading */
function confirmTerminate(row: InstanceRow) {
dialog.warning({
title: `终止实例 ${row.displayName}`,
content: '终止后实例不可恢复。是否同时保留引导卷?',
positiveText: '终止并删除引导卷',
negativeText: '终止但保留引导卷',
onPositiveClick: () => doTerminate(row, false),
onNegativeClick: () => doTerminate(row, true),
})
}
async function doTerminate(row: InstanceRow, preserveBootVolume: boolean) {
try {
await terminateInstance(row.cfgId, row.id, preserveBootVolume, scope.region || undefined)
message.success(`终止请求已提交:${row.displayName}`)
void rows.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '终止失败')
}
}
const columns = computed<DataTableColumns<InstanceRow>>(() => [
{
title: '实例',
@@ -147,7 +172,7 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
{
title: '操作',
key: 'actions',
width: 150,
width: 170,
render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [
row.lifecycleState === 'STOPPED'
@@ -166,6 +191,11 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'RESET') },
{ default: () => '重启' },
),
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', disabled: isEnded(row), onClick: () => confirmTerminate(row) },
{ default: () => '终止' },
),
]),
},
])
@@ -191,12 +221,12 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
<div class="flex flex-wrap items-center gap-2 px-4.5 py-3">
<NInputGroup class="!w-72 max-md:!w-full">
<NInputGroupLabel size="small">区间</NInputGroupLabel>
<NSelect
<CompartmentSelect
v-model:value="scope.compartmentId"
:options="scope.compOptions"
:compartments="scope.compartments.data ?? []"
:root-label="scope.rootLabel"
:loading="scope.compartments.loading"
:disabled="scope.compDisabled"
size="small"
/>
</NInputGroup>
<div class="max-w-72 min-w-40 flex-1 max-md:max-w-full">
+1 -1
View File
@@ -156,7 +156,7 @@ async function oauthLogin(provider: string) {
<!-- 登录卡 -->
<div class="w-[392px] max-w-full flex-none">
<div class="rounded-[14px] border border-line bg-card px-[30px] pt-[30px] pb-7 shadow-[0_18px_50px_rgba(20,20,19,.10),0_2px_8px_rgba(20,20,19,.05)]">
<div class="rounded-lg border border-line bg-card px-[30px] pt-[30px] pb-7 shadow-overlay">
<div class="font-serif text-[19px] font-semibold">欢迎回来</div>
<div v-if="bootLoading" class="flex items-center justify-center py-14">
+29 -19
View File
@@ -4,17 +4,17 @@ import {
NDataTable,
NInputGroup,
NInputGroupLabel,
NSelect,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { listSubnets, listVcns } from '@/api/networks'
import CompartmentSelect from '@/components/CompartmentSelect.vue'
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
import FootNote from '@/components/FootNote.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
import CreateVcnModal from '@/components/network/CreateVcnModal.vue'
import ReservedIpPanel from '@/components/network/ReservedIpPanel.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useRegionAlias } from '@/composables/useRegionAlias'
import { useAsync } from '@/composables/useAsync'
@@ -132,24 +132,30 @@ const columns = computed<DataTableColumns<VcnRow>>(() => [
{{ scope.currentConfig?.alias ?? '…' }}
</span>
</div>
<NButton v-if="!isDead" size="small" type="primary" @click="showCreate = true">
创建 VCN
</NButton>
<NInputGroup v-if="!isDead" class="!w-72 max-md:!w-full">
<NInputGroupLabel size="small">区间</NInputGroupLabel>
<CompartmentSelect
v-model:value="scope.compartmentId"
:compartments="scope.compartments.data ?? []"
:root-label="scope.rootLabel"
:loading="scope.compartments.loading"
:disabled="scope.compDisabled"
/>
</NInputGroup>
</div>
<DeadPlaceholder v-if="isDead" :last-error="scope.currentConfig?.lastError" />
<div v-else class="panel">
<div class="flex flex-wrap items-center gap-2 px-4.5 py-3">
<NInputGroup class="!w-72 max-md:!w-full">
<NInputGroupLabel size="small">区间</NInputGroupLabel>
<NSelect
v-model:value="scope.compartmentId"
:options="scope.compOptions"
:loading="scope.compartments.loading"
:disabled="scope.compDisabled"
size="small"
/>
</NInputGroup>
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5">
<div>
<div class="text-sm font-semibold">
VCN
</div>
<div class="mt-0.5 text-xs text-ink-3">
创建 VCN 默认启用 IPv6Oracle GUA /56并将默认安全列表替换为出入全放行如需收紧请再更新安全列表
</div>
</div>
<NButton size="small" type="primary" @click="showCreate = true">创建 VCN</NButton>
</div>
<NDataTable
:columns="columns"
@@ -158,11 +164,15 @@ const columns = computed<DataTableColumns<VcnRow>>(() => [
:scroll-x="920"
:row-key="(r: VcnRow) => r.id"
/>
<FootNote>
创建 VCN 默认启用 IPv6Oracle GUA /56)并将默认安全列表替换为出入全放行;如需收紧请再更新安全列表
</FootNote>
</div>
<ReservedIpPanel
v-if="!isDead"
:cfg-id="scope.currentConfig?.id ?? null"
:region="scope.region || undefined"
:compartment-id="scope.compartmentId || undefined"
/>
<CreateVcnModal
v-model:show="showCreate"
:cfg-id="scope.currentConfig?.id ?? null"
+370
View File
@@ -0,0 +1,370 @@
<script setup lang="ts">
import {
NButton,
NDataTable,
NInput,
NInputGroup,
NInputGroupLabel,
NRadio,
NRadioGroup,
NSwitch,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { createBucket, deleteBucket, listBuckets, updateBucket } from '@/api/objectstorage'
import { listLimits } from '@/api/tenant'
import CompartmentSelect from '@/components/CompartmentSelect.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import ObjectBrowser from '@/components/objectstorage/ObjectBrowser.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useScopeStore } from '@/stores/scope'
import type { Bucket } from '@/types/api'
import { useToast } from '@/composables/useToast'
const scope = useScopeStore()
const route = useRoute()
const router = useRouter()
const message = useToast()
const isDead = computed(() => scope.currentConfig?.aliveStatus === 'dead')
const cfgId = computed(() => scope.currentConfig?.id ?? null)
/** 当前浏览的桶经 query 保持,浏览器返回键可退回列表 */
const activeBucket = computed(() => (route.query.bucket as string) || '')
/** 当前桶的列表信息(版本控制状态供编辑保存确认);直达链接列表未回时为 undefined */
const activeBucketInfo = computed(() =>
(buckets.data.value ?? []).find((b) => b.name === activeBucket.value),
)
function openBucket(name: string) {
void router.push({ query: { ...route.query, bucket: name } })
}
function backToList() {
const q = { ...route.query }
delete q.bucket
void router.push({ query: q })
}
const buckets = useAsync<Bucket[]>(async () => {
if (!cfgId.value || isDead.value) return []
try {
return await listBuckets(cfgId.value, scope.region || undefined, scope.compartmentId || undefined)
} catch {
return []
}
}, false)
watch([cfgId, () => scope.region, () => scope.compartmentId], () => void buckets.run(), {
immediate: true,
})
/** 存储用量来自 limits 的 storage-bytes(区域级,覆盖全部区间),桶求和只反映当前区间 */
const storageLimit = useAsync(async () => {
if (!cfgId.value || isDead.value) return null
try {
const rows = await listLimits(cfgId.value, {
service: 'object-storage',
name: 'storage-bytes',
region: scope.region || undefined,
withAvailability: true,
})
return rows.find((r) => r.name === 'storage-bytes') ?? null
} catch {
return null
}
}, false)
watch([cfgId, () => scope.region], () => void storageLimit.run(), { immediate: true })
/** 付费号上限为 int64 max(≈8 EiB),视为无硬性上限,不再当分母展示 */
const UNCAPPED_BYTES = 2 ** 60
const usedBytes = computed(() => storageLimit.data.value?.used ?? 0)
const capBytes = computed(() => storageLimit.data.value?.value ?? 0)
const uncapped = computed(() => capBytes.value <= 0 || capBytes.value > UNCAPPED_BYTES)
const usedPercent = computed(() =>
uncapped.value ? 0 : Math.min(100, (usedBytes.value / capBytes.value) * 100),
)
// ---- 创建桶 ----
const showCreate = ref(false)
const creating = ref(false)
const createForm = reactive({ name: '', publicRead: false, storageTier: 'Standard', versioningOn: false })
function openCreate() {
Object.assign(createForm, { name: '', publicRead: false, storageTier: 'Standard', versioningOn: false })
showCreate.value = true
}
async function doCreate() {
if (!cfgId.value) return
creating.value = true
try {
await createBucket(cfgId.value, {
region: scope.region || undefined,
compartmentId: scope.compartmentId || undefined,
...createForm,
})
message.success(`存储桶 ${createForm.name} 已创建`)
showCreate.value = false
void buckets.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
creating.value = false
}
}
// ---- 桶设置 ----
const showSettings = ref(false)
const savingSettings = ref(false)
const settingsForm = reactive({ bucket: '', publicRead: false, versioningOn: false })
function openSettings(b: Bucket) {
Object.assign(settingsForm, {
bucket: b.name,
publicRead: b.visibility === 'ObjectRead',
versioningOn: b.versioningOn,
})
showSettings.value = true
}
async function doSaveSettings() {
if (!cfgId.value) return
savingSettings.value = true
try {
await updateBucket(cfgId.value, settingsForm.bucket, {
region: scope.region || undefined,
publicRead: settingsForm.publicRead,
versioningOn: settingsForm.versioningOn,
})
message.success('桶设置已更新')
showSettings.value = false
void buckets.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingSettings.value = false
}
}
async function remove(b: Bucket) {
if (!cfgId.value) return
try {
const { queued } = await deleteBucket(cfgId.value, b.name, scope.region || undefined)
if (queued) message.success(`存储桶 ${b.name} 非空,已转后台清空后删除,稍后刷新查看`)
else message.success(`存储桶 ${b.name} 已删除`)
void buckets.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const columns = computed<DataTableColumns<Bucket>>(() => [
{
title: '名称',
key: 'name',
minWidth: 180,
// 超长桶名靠 NEllipsis 截断,悬停 tooltip 看全名
ellipsis: { tooltip: true },
render: (b) =>
h(
'span',
{
class:
'cursor-pointer font-medium text-ink border-b border-dashed border-ink-3/50 hover:text-accent',
onClick: () => openBucket(b.name),
},
b.name,
),
},
{
title: '可见性',
key: 'visibility',
width: 100,
render: (b) =>
b.visibility === 'ObjectRead'
? h(StatusBadge, { kind: 'prov', label: '公共读', dot: false })
: h(StatusBadge, { kind: 'check', label: '私有', dot: false }),
},
{ title: '存储层', key: 'storageTier', width: 100 },
{
title: '版本控制',
key: 'versioningOn',
width: 90,
render: (b) => h('span', { class: 'text-[13px] text-ink-2' }, b.versioningOn ? '开' : '关'),
},
{
title: '对象数',
key: 'approximateCount',
width: 90,
render: (b) => h('span', { class: 'mono' }, String(b.approximateCount)),
},
{
title: '已用容量',
key: 'approximateSize',
width: 110,
render: (b) => h('span', { class: 'mono' }, fmtBytes(b.approximateSize)),
},
{
title: '创建时间',
key: 'timeCreated',
width: 150,
render: (b) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(b.timeCreated)),
},
{
title: '操作',
key: 'actions',
width: 160,
render: (b) =>
h('div', { class: 'flex items-center gap-1' }, [
h(
NButton,
{ size: 'tiny', quaternary: true, onClick: () => openSettings(b) },
{ default: () => '设置' },
),
h(
ConfirmPop,
{
title: `删除存储桶?`,
content: '将后台清空全部对象,不可恢复。',
confirmText: '删除',
onConfirm: () => remove(b),
},
{
trigger: () =>
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error' },
{ default: () => '删除' },
),
},
),
]),
},
])
</script>
<template>
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
<template v-if="!activeBucket">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-baseline gap-2.5">
<h1 class="page-title">对象存储</h1>
<span class="text-[13px] text-ink-3">
{{ buckets.data.value?.length ?? '…' }} 个桶 · {{ scope.currentConfig?.alias ?? '…' }}
</span>
</div>
<NButton v-if="!isDead" size="small" type="primary" @click="openCreate">创建存储桶</NButton>
</div>
<DeadPlaceholder v-if="isDead" :last-error="scope.currentConfig?.lastError" />
<div v-else class="panel">
<div class="flex flex-wrap items-center gap-2 px-4.5 py-3">
<NInputGroup class="!w-72 max-md:!w-full">
<NInputGroupLabel size="small">区间</NInputGroupLabel>
<CompartmentSelect
v-model:value="scope.compartmentId"
:compartments="scope.compartments.data ?? []"
:root-label="scope.rootLabel"
:loading="scope.compartments.loading"
:disabled="scope.compDisabled"
/>
</NInputGroup>
</div>
<NDataTable
:columns="columns"
:data="buckets.data.value ?? []"
:loading="buckets.loading.value"
:scroll-x="960"
:row-key="(b: Bucket) => b.name"
/>
<div
class="flex items-center gap-3 border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-2"
>
<span>存储用量</span>
<template v-if="!uncapped">
<span class="h-[5px] w-full max-w-70 overflow-hidden rounded-full bg-wash">
<span class="block h-full rounded-full bg-ok" :style="{ width: `${usedPercent}%` }" />
</span>
<span class="mono text-ink-3">{{ fmtBytes(usedBytes) }} / {{ fmtBytes(capBytes) }}</span>
</template>
<span v-else class="mono text-ink-3">已用 {{ fmtBytes(usedBytes) }}</span>
</div>
</div>
</template>
<ObjectBrowser
v-else
:cfg-id="cfgId"
:region="scope.region || undefined"
:bucket="activeBucket"
:versioning-on="activeBucketInfo?.versioningOn"
:visibility="activeBucketInfo?.visibility"
:namespace="activeBucketInfo?.namespace"
@back="backToList"
/>
<FormModal
:show="showCreate"
title="创建存储桶"
:width="440"
:submitting="creating"
:submit-disabled="!createForm.name.trim()"
submit-text="创建"
@update:show="showCreate = $event"
@submit="doCreate"
>
<FormField label="名称" required hint=" namespace 内唯一,创建后不可改名">
<NInput v-model:value="createForm.name" placeholder="my-bucket" class="mono" />
</FormField>
<FormField label="可见性">
<NRadioGroup v-model:value="createForm.publicRead">
<NRadio :value="false">私有</NRadio>
<NRadio :value="true">公共读</NRadio>
</NRadioGroup>
</FormField>
<FormField label="存储层" hint="Archive 单价更低,取回需先恢复( 1 小时)">
<NRadioGroup v-model:value="createForm.storageTier">
<NRadio value="Standard">Standard</NRadio>
<NRadio value="Archive">Archive</NRadio>
</NRadioGroup>
</FormField>
<FormField label="版本控制" hint="覆盖 / 删除时保留历史版本">
<NSwitch v-model:value="createForm.versioningOn" size="small" />
</FormField>
</FormModal>
<FormModal
:show="showSettings"
title="桶设置"
:width="440"
:submitting="savingSettings"
submit-text="保存"
@update:show="showSettings = $event"
@submit="doSaveSettings"
>
<FormField label="">
<div class="mono -mt-1 text-[12.5px] leading-relaxed break-all text-ink-2">
{{ settingsForm.bucket }}
</div>
</FormField>
<FormField label="可见性" hint="公共读:任何人可经 URL 读取对象,请谨慎开启">
<NRadioGroup v-model:value="settingsForm.publicRead">
<NRadio :value="false">私有</NRadio>
<NRadio :value="true">公共读</NRadio>
</NRadioGroup>
</FormField>
<FormField label="版本控制" hint="关闭仅停止产生新版本,已有历史版本保留">
<NSwitch v-model:value="settingsForm.versioningOn" size="small" />
</FormField>
</FormModal>
</div>
</template>
+77 -31
View File
@@ -147,19 +147,25 @@ watch(
},
)
/** 开关点击即全量 PUT(与 AI 设置一致);失败重拉服务端值回滚 UI */
async function saveEvents() {
savingEvents.value = true
try {
await updateNotifyEvents({ ...eventForm.value })
message.success('通知管理已保存')
void events.run({ silent: true })
message.success('已保存,立即生效')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
void events.run({ silent: true })
} finally {
savingEvents.value = false
}
}
function setEventField(key: keyof NotifyEventsSetting, v: boolean) {
eventForm.value[key] = v
void saveEvents()
}
// ---- 通知方式:多渠道(webhook/ntfy/bark/smtp)----
const channels = useAsync(listNotifyChannels)
@@ -236,7 +242,9 @@ const customHeaderMode = ref(false)
function pickHeader(v: string) {
customHeaderMode.value = false
if (securityForm.value.realIpHeader === v) return
securityForm.value.realIpHeader = v
void saveSecurity()
}
function pickCustomHeader() {
@@ -257,19 +265,31 @@ watch(
},
)
/** 控件变更即全量 PUT(与 AI 设置一致);失败重拉服务端值回滚 UI */
async function saveSecurity() {
savingSecurity.value = true
try {
await updateSecuritySetting({ ...securityForm.value })
message.success('安全设置已保存,立即生效')
message.success('已保存,立即生效')
void security.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
void security.run({ silent: true })
} finally {
savingSecurity.value = false
}
}
/** 访问防护数字输入失焦/回车提交 */
function commitSecurityNum(
key: 'loginFailLimit' | 'loginLockMinutes' | 'ipRateRps' | 'ipRateBurst',
v: number | null,
) {
if (v == null || v === securityForm.value[key]) return
securityForm.value[key] = v
void saveSecurity()
}
async function saveTaskSetting() {
const limit = authFailLimit.value
if (limit == null || limit < 1 || limit > 10) {
@@ -279,15 +299,23 @@ async function saveTaskSetting() {
savingTask.value = true
try {
await updateTaskSetting({ snatchAuthFailLimit: limit })
message.success('任务设置已保存')
message.success('已保存,立即生效')
void taskSetting.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
void taskSetting.run({ silent: true })
} finally {
savingTask.value = false
}
}
/** 熔断阈值失焦/回车提交 */
function commitAuthFailLimit(v: number | null) {
if (v == null || v === taskSetting.data.value?.snatchAuthFailLimit) return
authFailLimit.value = v
void saveTaskSetting()
}
const initialLoading = computed(
() =>
(setting.loading.value && !setting.data.value) ||
@@ -359,7 +387,7 @@ async function sendTest() {
<div class="min-w-0">
<div class="text-sm font-semibold">通知管理</div>
<div class="mt-0.5 text-xs text-ink-3">
按事件类型控制是否推送事件仅在状态变化时触发
按事件类型控制是否推送开关即时生效事件仅在状态变化时触发
</div>
</div>
<span
@@ -391,17 +419,16 @@ async function sendTest() {
>
模板
</button>
<NSwitch v-model:value="eventForm[row.key]" size="small" />
<NSwitch
:value="eventForm[row.key]"
size="small"
:loading="savingEvents"
@update:value="(v: boolean) => setEventField(row.key, v)"
/>
</div>
</div>
</template>
</div>
<div class="flex items-center gap-2 border-t border-line-soft px-4.5 py-3.5">
<NButton size="small" type="primary" :loading="savingEvents" @click="saveEvents">
保存
</NButton>
<span class="text-xs text-ink-3">全部开关整体保存立即生效</span>
</div>
</div>
<!-- :通知方式 + 送达规则 -->
@@ -516,16 +543,18 @@ async function sendTest() {
<div class="px-4.5 pt-3 pb-4.5">
<div class="flex flex-wrap items-center gap-4">
<AppInputNumber
v-model:value="authFailLimit"
:value="authFailLimit"
:min="1"
:max="10"
size="large"
class="!w-28"
:update-value-on-input="false"
@update:value="commitAuthFailLimit"
/>
<div class="min-w-0">
<div class="text-[13px] font-semibold">熔断阈值(连续 NotAuthenticated 次数)</div>
<div class="mt-0.5 text-xs text-ink-3">
范围 1-10,默认 3;保存后下一次抢机执行按新阈值判定
范围 1-10,默认 3;失焦即保存,下一次抢机执行按新阈值判定
</div>
</div>
</div>
@@ -545,12 +574,6 @@ async function sendTest() {
<div class="mt-1 text-[13px] font-semibold">任务页手动启用</div>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<NButton size="small" type="primary" :loading="savingTask" @click="saveTaskSetting">
保存
</NButton>
<span class="text-xs text-ink-3">保存后立即生效,无需重启</span>
</div>
</div>
<FootNote>
熔断只针对鉴权类失败(API Key 失效);容量不足等业务失败不计入,抢机会持续重试
@@ -570,16 +593,40 @@ async function sendTest() {
</div>
<div class="grid grid-cols-2 gap-x-4 px-4.5 py-3.5 max-md:grid-cols-1">
<FormField label="登录失败阈值" hint="窗口内同一 IP+用户名连续失败达到该次数即锁定(1-20)">
<AppInputNumber v-model:value="securityForm.loginFailLimit" :min="1" :max="20" />
<AppInputNumber
:value="securityForm.loginFailLimit"
:min="1"
:max="20"
:update-value-on-input="false"
@update:value="(v: number | null) => commitSecurityNum('loginFailLimit', v)"
/>
</FormField>
<FormField label="锁定时长(分钟)" hint="锁定持续时间,同时是失败计数窗口(1-1440)">
<AppInputNumber v-model:value="securityForm.loginLockMinutes" :min="1" :max="1440" />
<AppInputNumber
:value="securityForm.loginLockMinutes"
:min="1"
:max="1440"
:update-value-on-input="false"
@update:value="(v: number | null) => commitSecurityNum('loginLockMinutes', v)"
/>
</FormField>
<FormField label="限速(req/s)" hint="每 IP 令牌桶每秒补充令牌数(1-100)">
<AppInputNumber v-model:value="securityForm.ipRateRps" :min="1" :max="100" />
<AppInputNumber
:value="securityForm.ipRateRps"
:min="1"
:max="100"
:update-value-on-input="false"
@update:value="(v: number | null) => commitSecurityNum('ipRateRps', v)"
/>
</FormField>
<FormField label="突发额度" hint="令牌桶容量,允许的瞬时突发请求数(1-500)">
<AppInputNumber v-model:value="securityForm.ipRateBurst" :min="1" :max="500" />
<AppInputNumber
:value="securityForm.ipRateBurst"
:min="1"
:max="500"
:update-value-on-input="false"
@update:value="(v: number | null) => commitSecurityNum('ipRateBurst', v)"
/>
</FormField>
</div>
</div>
@@ -629,6 +676,7 @@ async function sendTest() {
v-model:value="securityForm.realIpHeader"
class="mono mt-2 !w-70"
placeholder="自定义头名,如 X-Client-IP"
@change="() => void saveSecurity()"
/>
<div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3">
头名仅限字母数字与连字符
@@ -638,14 +686,12 @@ async function sendTest() {
label="面板地址"
hint="面板公网基址,优先于 PUBLIC_URL 环境变量;日志回传一键创建与 OAuth 回调以此拼接 URL"
>
<NInput v-model:value="securityForm.appUrl" placeholder="https://demo.example.com" />
<NInput
v-model:value="securityForm.appUrl"
placeholder="https://demo.example.com"
@change="() => void saveSecurity()"
/>
</FormField>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="savingSecurity" @click="saveSecurity">
保存
</NButton>
<span class="text-xs text-ink-3">整体保存,立即生效,无需重启</span>
</div>
</div>
<FootNote>
登录锁定为内存计数,重启服务即清零;真实IP解析对 X-Forwarded-For
+11 -11
View File
@@ -5,6 +5,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks'
import EmptyCard from '@/components/EmptyCard.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TaskFormModal from '@/components/task/TaskFormModal.vue'
import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue'
@@ -146,8 +147,8 @@ const features = computed(() => {
>
<NSpin size="small" />
</div>
<div v-else-if="task.error.value" class="panel px-5 py-10 text-center text-[13px] text-ink-3">
{{ task.error.value }}
<div v-else-if="task.error.value" class="panel">
<EmptyCard title="任务加载失败" :note="task.error.value" />
</div>
<div
v-else-if="task.data.value"
@@ -232,11 +233,10 @@ const features = computed(() => {
<!-- 事件流 -->
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-5 py-3.5">
<div class="text-sm font-semibold">事件流</div>
<div class="text-xs text-ink-3">最新事件置顶 · 保留近 100 </div>
</div>
<div class="max-h-[560px] overflow-y-auto px-5.5 pt-4 pb-3">
<PanelHeader title="事件流">
<span class="text-xs text-ink-3">最新事件置顶 · 保留近 100 </span>
</PanelHeader>
<div class="max-h-[560px] overflow-y-auto px-5 pt-4 pb-3">
<div
v-for="(log, i) in logs.data.value ?? []"
:key="log.id"
@@ -282,8 +282,8 @@ const features = computed(() => {
<!-- 右栏 -->
<div class="flex flex-col gap-4">
<div class="panel">
<div class="border-b border-line-soft px-5 py-3.5 text-sm font-semibold">任务参数</div>
<div class="px-5 pt-1 pb-2.5">
<PanelHeader title="任务参数" />
<div class="px-4.5 pt-1 pb-2.5">
<div
v-for="[k, v] in params"
:key="k"
@@ -295,8 +295,8 @@ const features = computed(() => {
</div>
</div>
<div class="panel">
<div class="border-b border-line-soft px-5 py-3.5 text-sm font-semibold">运维特性</div>
<div class="px-5 py-2">
<PanelHeader title="运维特性" />
<div class="px-4.5 py-2">
<div v-for="[t, d] in features" :key="t" class="flex gap-2.5 py-2.5">
<svg
class="mt-0.5 h-[15px] w-[15px] flex-none text-ok"
+17 -17
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NPopconfirm, NSelect, NSpin, NTabPane, NTabs, NTooltip } from 'naive-ui'
import { NButton, NSelect, NSpin, NTabPane, NTabs, NTooltip } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
@@ -7,7 +7,10 @@ import { deleteConfig, getConfig, verifyConfig } from '@/api/configs'
import { listIdentityDomains } from '@/api/tenant'
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
import AuditLogTab from '@/components/tenant/AuditLogTab.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import CostTab from '@/components/tenant/CostTab.vue'
import InvoiceTab from '@/components/tenant/InvoiceTab.vue'
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
import IdpTab from '@/components/tenant/IdpTab.vue'
import MiscTab from '@/components/tenant/MiscTab.vue'
@@ -98,11 +101,8 @@ async function remove() {
>
<NSpin size="small" />
</div>
<div
v-else-if="config.error.value"
class="panel px-5 py-10 text-center text-[13px] text-ink-3"
>
{{ config.error.value }}
<div v-else-if="config.error.value" class="panel">
<EmptyCard title="租户加载失败" :note="config.error.value" />
</div>
<div v-if="config.data.value" class="panel px-5 pt-4 pb-0">
@@ -136,20 +136,18 @@ async function remove() {
<AccountTypeBadge :type="config.data.value.accountType" />
<div class="flex-1" />
<NButton size="small" :loading="verifying" @click="verify">立即测活</NButton>
<NPopconfirm negative-text="取消" positive-text="删除" @positive-click="remove">
<ConfirmPop
:title="`删除「${config.data.value.alias}」?`"
content="将清理面板内关联数据。"
note="云端日志回传链路请先在「其他」页销毁"
confirm-text="删除"
placement="bottom-end"
:on-confirm="remove"
>
<template #trigger>
<NButton size="small" type="error" ghost>删除租户</NButton>
</template>
<div class="max-w-80 text-[13px] leading-relaxed">
<div class="font-medium">删除{{ config.data.value.alias }}</div>
<div class="mt-1 text-xs text-ink-3">
将清理面板内关联数据后台任务测活 / 成本快照回传事件Webhook 密钥与 AI 渠道
</div>
<div class="mt-0.5 text-xs text-ink-3">
OCI 云端资源不受影响云端日志回传链路请先在其他页销毁
</div>
</div>
</NPopconfirm>
</ConfirmPop>
</div>
<div class="mt-3 grid grid-cols-4 gap-4 pb-4 max-lg:grid-cols-2 max-md:grid-cols-1">
@@ -176,6 +174,7 @@ async function remove() {
<NTabPane name="idp" tab="IDP" />
<NTabPane name="users" tab="用户" />
<NTabPane name="cost" tab="成本" />
<NTabPane name="invoice" tab="发票" />
<NTabPane name="subs" tab="订阅" />
<NTabPane name="audit" tab="审计日志" />
<NTabPane name="misc" tab="其他" />
@@ -200,6 +199,7 @@ async function remove() {
<IdpTab v-else-if="tab === 'idp'" :key="`idp-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
<UsersTab v-else-if="tab === 'users'" :key="`users-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
<CostTab v-else-if="tab === 'cost'" :cfg-id="cfgId" />
<InvoiceTab v-else-if="tab === 'invoice'" :cfg-id="cfgId" />
<SubsTab v-else-if="tab === 'subs'" :cfg-id="cfgId" :account-type="config.data.value.accountType" />
<AuditLogTab v-else-if="tab === 'audit'" :cfg-id="cfgId" />
<MiscTab v-else :key="`misc-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
+23 -18
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NPopconfirm, NPopover, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NPopover, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref, watch, type VNodeChild } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { listAiChannels } from '@/api/aigateway'
import { deleteConfig, listConfigs, verifyConfig } from '@/api/configs'
import { listTasks } from '@/api/tasks'
import ConfirmPop from '@/components/ConfirmPop.vue'
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
import EditConfigModal from '@/components/config/EditConfigModal.vue'
import ImportConfigModal from '@/components/config/ImportConfigModal.vue'
@@ -31,7 +32,13 @@ function reloadConfigs() {
}
const keyword = ref('')
const UNGROUPED = '__ungrouped__'
const groupFilter = ref<string | null>(null)
/** 分组筛选跨页面记忆:切走再回不重选;选项消失时由下方 watch 回退全部 */
const GROUP_KEY = 'oci-portal:tenants:group'
const groupFilter = ref<string | null>(localStorage.getItem(GROUP_KEY))
watch(groupFilter, (v) => {
if (v === null) localStorage.removeItem(GROUP_KEY)
else localStorage.setItem(GROUP_KEY, v)
})
const verifying = ref<number | null>(null)
const showImport = ref(false)
const showEdit = ref(false)
@@ -50,6 +57,12 @@ const groupOptions = computed(() => {
return options
})
// 记忆的分组已被删除 / 重命名时回退「全部」,避免列表看似为空
watch(groupOptions, (opts) => {
const g = groupFilter.value
if (g !== null && opts.length && !opts.some((o) => o.value === g)) groupFilter.value = null
})
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
let list = configs.data.value ?? []
@@ -235,25 +248,17 @@ function renderActions(row: OciConfigSummary): VNodeChild {
),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => edit(row) }, { default: () => '修改' }),
h(
NPopconfirm,
{ onPositiveClick: () => remove(row), negativeText: '取消', positiveText: '删除' },
ConfirmPop,
{
title: `删除「${row.alias}」?`,
content: '将清理面板内关联数据。',
note: '云端日志回传链路请先在「其他」页销毁',
confirmText: '删除',
onConfirm: () => remove(row),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
default: () =>
h('div', { class: 'max-w-80 text-[13px] leading-relaxed' }, [
h('div', { class: 'font-medium' }, `删除「${row.alias}」?`),
h(
'div',
{ class: 'mt-1 text-xs text-ink-3' },
'将清理面板内关联数据:后台任务、测活 / 成本快照、回传事件、Webhook 密钥与 AI 渠道。',
),
h(
'div',
{ class: 'mt-0.5 text-xs text-ink-3' },
'OCI 云端资源不受影响;云端日志回传链路请先在「其他」页销毁。',
),
]),
},
),
])
+162 -36
View File
@@ -2,21 +2,30 @@
import {
NButton,
NDataTable,
NPopconfirm,
NSpin,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { deleteVcn, enableVcnIpv6, getVcn, listSecurityLists, listSubnets, renameVcn } from '@/api/networks'
import FootNote from '@/components/FootNote.vue'
import {
deleteVcn,
enableVcnIpv6,
getVcn,
listSecurityLists,
listSubnets,
renameVcn,
updateSecurityList,
} from '@/api/networks'
import ConfirmPop from '@/components/ConfirmPop.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import SecurityRuleEditor from '@/components/network/SecurityRuleEditor.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
import RenameModal from '@/components/RenameModal.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { Ipv6Step, SecurityList, Subnet } from '@/types/api'
import type { Ipv6Step, SecurityList, SecurityRule, Subnet } from '@/types/api'
import { useToast } from '@/composables/useToast'
const route = useRoute()
@@ -35,6 +44,14 @@ const seclists = useAsync(() => listSecurityLists(cfgId.value, vcnId.value, regi
const ipv6Steps = ref<Ipv6Step[] | null>(null)
const enabling = ref(false)
/** VCN 与全部子网都已有 IPv6 段即视为启用完成,执行按钮禁用 */
const ipv6Done = computed(() => {
const v = vcn.data.value
const subs = subnets.data.value
if (!v?.ipv6CidrBlocks?.length || !subs?.length) return false
return subs.every((s) => !!s.ipv6CidrBlock)
})
async function runEnableIpv6() {
enabling.value = true
try {
@@ -52,18 +69,54 @@ async function runEnableIpv6() {
}
}
const removing = ref(false)
async function removeVcn() {
removing.value = true
try {
await deleteVcn(cfgId.value, vcnId.value, region.value)
message.success('VCN 已删除')
void router.push({ name: 'network' })
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
removing.value = false
}
}
/** 已启用 IPv6 的子网计数,便捷功能行展示进度 */
const ipv6Count = computed(() => {
const subs = subnets.data.value ?? []
return `${subs.filter((s) => !!s.ipv6CidrBlock).length}/${subs.length}`
})
/** 默认安全列表当前是否已是出入全放行 */
const defaultAllOpen = computed(() => {
const id = vcn.data.value?.defaultSecurityListId
const list = (seclists.data.value ?? []).find((l) => l.id === id)
if (!list) return false
return (
(list.ingressRules ?? []).some((r) => r.protocol === 'all' && r.source === '0.0.0.0/0') &&
(list.egressRules ?? []).some((r) => r.protocol === 'all' && r.destination === '0.0.0.0/0')
)
})
/** 一键放行:默认安全列表替换为出入全放行,与创建 VCN 的默认语义一致;
* VCN 未启用 IPv6 时不写 ::/0(OCI 会拒绝) */
async function openAllRules() {
const v = vcn.data.value
if (!v) return
try {
const ingress: SecurityRule[] = [{ protocol: 'all', source: '0.0.0.0/0', isStateless: false }]
const egress: SecurityRule[] = [{ protocol: 'all', destination: '0.0.0.0/0', isStateless: false }]
if (v.ipv6CidrBlocks?.length) {
ingress.push({ protocol: 'all', source: '::/0', isStateless: false })
egress.push({ protocol: 'all', destination: '::/0', isStateless: false })
}
await updateSecurityList(cfgId.value, v.defaultSecurityListId, {
region: region.value,
ingressRules: ingress,
egressRules: egress,
})
message.success('默认安全列表已放行全部出入流量')
void seclists.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
@@ -141,6 +194,16 @@ function ruleSummary(list: SecurityList): string {
}
const seclistColumns: DataTableColumns<SecurityList> = [
{
type: 'expand',
renderExpand: (row) =>
h(SecurityRuleEditor, {
cfgId: cfgId.value,
list: row,
region: region.value,
onUpdated: () => void seclists.run({ silent: true }),
}),
},
{
title: '安全列表',
key: 'displayName',
@@ -195,8 +258,8 @@ const stepColumns: DataTableColumns<Ipv6Step> = [
>
<NSpin size="small" />
</div>
<div v-else-if="vcn.error.value" class="panel px-5 py-10 text-center text-[13px] text-ink-3">
{{ vcn.error.value }}
<div v-else-if="vcn.error.value" class="panel">
<EmptyCard title="VCN 加载失败" :note="vcn.error.value" />
</div>
<div v-if="vcn.data.value" class="panel px-5 py-4">
@@ -217,14 +280,18 @@ const stepColumns: DataTableColumns<Ipv6Step> = [
/>
<div class="flex-1" />
<NButton size="small" @click="showRename = true">重命名</NButton>
<NPopconfirm @positive-click="removeVcn">
<ConfirmPop
title="删除 VCN"
content="其关联资源将一并删除。"
note="子网仍被实例占用时会失败,需先终止实例"
confirm-text="删除"
placement="bottom-end"
:on-confirm="removeVcn"
>
<template #trigger>
<NButton size="small" type="error" ghost :loading="removing" :disabled="removing">
{{ removing ? '删除中…' : '删除 VCN' }}
</NButton>
<NButton size="small" type="error" ghost>删除 VCN</NButton>
</template>
删除 VCN将级联删除其子网网关路由等关联资源子网仍被实例占用时会失败需先终止实例
</NPopconfirm>
</ConfirmPop>
</div>
<div class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1">
<div class="min-w-0">
@@ -248,6 +315,77 @@ const stepColumns: DataTableColumns<Ipv6Step> = [
</div>
</div>
<div v-if="vcn.data.value" class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-2.5">
<div class="text-[13.5px] font-semibold">便捷功能</div>
<div class="text-xs text-ink-3">执行前均会二次确认</div>
</div>
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-2.5">
<span
class="flex h-6 w-6 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</span>
<span class="text-[13px] font-semibold">一键启用 IPv6</span>
<span class="min-w-0 flex-1 truncate text-xs text-ink-3">
追加 /56 前缀并逐子网分配 /64 与路由,实例内需自行 dhclient -6
</span>
<StatusBadge v-if="ipv6Done" kind="run" :label="`已全部启用 · ${ipv6Count}`" :dot="false" />
<ConfirmPop
title="执行一键启用 IPv6"
content="为 VCN 与全部子网补齐 IPv6 段及路由,已配置的自动跳过。"
kind="warn"
confirm-text="执行"
placement="bottom-end"
:on-confirm="runEnableIpv6"
>
<template #trigger>
<NButton size="tiny" type="primary" :disabled="ipv6Done" :loading="enabling">
{{ ipv6Done ? '已完成' : ipv6Steps ? '重新执行' : '执行' }}
</NButton>
</template>
</ConfirmPop>
</div>
<div class="flex flex-wrap items-center gap-2.5 px-4.5 py-2.5">
<span
class="flex h-6 w-6 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="M9 12l2 2 4-4" />
</svg>
</span>
<span class="text-[13px] font-semibold">一键放行安全规则</span>
<span class="min-w-0 flex-1 truncate text-xs text-ink-3">
默认安全列表替换为出入全放行,适合由实例内防火墙接管
</span>
<StatusBadge v-if="defaultAllOpen" kind="run" label="已全放行" :dot="false" />
<ConfirmPop
title="替换为出入全放行?"
content="默认安全列表的原有规则将被覆盖,自建列表不受影响。"
:note="
vcn.data.value.ipv6CidrBlocks?.length
? '含 IPv6(::/0)规则'
: 'VCN 未启用 IPv6,仅放行 IPv4'
"
kind="warn"
confirm-text="执行"
placement="bottom-end"
:on-confirm="openAllRules"
>
<template #trigger>
<NButton size="tiny" type="primary" :disabled="defaultAllOpen">
{{ defaultAllOpen ? '已放行' : '一键放行' }}
</NButton>
</template>
</ConfirmPop>
</div>
</div>
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5 text-sm font-semibold">子网</div>
<NDataTable
@@ -260,7 +398,10 @@ const stepColumns: DataTableColumns<Ipv6Step> = [
</div>
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5 text-sm font-semibold">安全列表</div>
<div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">安全列表</div>
<div class="mt-0.5 text-xs text-ink-3">点击行首箭头展开规则;行内编辑,保存即提交整表替换</div>
</div>
<NDataTable
:columns="seclistColumns"
:data="seclists.data.value ?? []"
@@ -268,33 +409,18 @@ const stepColumns: DataTableColumns<Ipv6Step> = [
:scroll-x="600"
:row-key="(r: SecurityList) => r.id"
/>
<FootNote>
更新安全列表时规则字段一经提供即全量替换;protocol 取 all / 6(TCP) / 17(UDP) / 1(ICMP) /
58(ICMPv6)
</FootNote>
</div>
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">一键启用 IPv6</div>
<NButton size="small" type="primary" :loading="enabling" @click="runEnableIpv6">
{{ ipv6Steps ? '重新执行' : '执行' }}
</NButton>
<div v-if="ipv6Steps" class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5 text-sm font-semibold">
IPv6 执行报告
</div>
<NDataTable
v-if="ipv6Steps"
:columns="stepColumns"
:data="ipv6Steps"
:scroll-x="620"
:row-key="(r: Ipv6Step) => r.step"
/>
<div v-else class="px-4.5 py-4 text-[13px] text-ink-3">
依次执行:VCN 分配 /56 → 子网分配 /64 → 默认路由补 ::/0 → 默认安全列表放行 IPv6
出方向;全部步骤幂等,已配置的自动跳过。
</div>
<FootNote>
只改默认路由表与默认安全列表;实例要实际获得 IPv6 还需在创建实例 / VNIC 时启用 IPv6 分配
</FootNote>
</div>
<RenameModal
+21 -11
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import RFB from '@novnc/novnc'
import { NButton, NSpin } from 'naive-ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
import { useAuthStore } from '@/stores/auth'
import { darkTokens as dk } from '@/theme/tokens'
/** 网页 VNC 独立页:从实例详情新标签页打开,独占整页无侧栏 */
/** 网页 VNC 独立页:从实例详情新标签页打开,独占整页无侧栏;
* 不随主题切换,恒用暗色 token 色板 */
const route = useRoute()
const auth = useAuthStore()
@@ -18,6 +20,11 @@ const instanceName = (route.query.name as string) || instanceId
const screen = ref<HTMLDivElement | null>(null)
const phase = ref<'idle' | 'creating' | 'connecting' | 'connected' | 'closed'>('idle')
/** 顶栏状态点:连上绿 / 断开红 / 过渡黄 */
const phaseColor = computed(() =>
phase.value === 'connected' ? dk.ok : phase.value === 'closed' ? dk.err : dk.warn,
)
const error = ref('')
// 适应窗口时 noVNC 按容器缩放画面;文本控制台缩放采样会糊 / 碎,可切原始 1:1
const fitScreen = ref(true)
@@ -104,13 +111,15 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="flex h-screen flex-col bg-[#141413]">
<div class="flex h-10 shrink-0 items-center gap-3 px-4 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 }} · 网页 VNC</span>
<div class="flex h-screen flex-col" :style="{ background: dk.bg }">
<div
class="flex h-10 shrink-0 items-center gap-3 px-4 text-[12.5px]"
:style="{ color: dk.ink2 }"
>
<span class="inline-block h-2 w-2 rounded-full" :style="{ background: phaseColor }" />
<span class="truncate font-medium" :style="{ color: dk.ink }">
{{ instanceName }} · 网页 VNC
</span>
<div class="flex-1" />
<NButton size="tiny" :disabled="phase !== 'connected'" @click="toggleFit">
{{ fitScreen ? '原始大小' : '适应窗口' }}
@@ -120,11 +129,12 @@ onBeforeUnmount(() => {
重新连接
</NButton>
</div>
<div class="relative min-h-0 flex-1 overflow-hidden bg-[#1c1b1a]">
<div class="relative min-h-0 flex-1 overflow-hidden" :style="{ background: dk.card }">
<div ref="screen" class="absolute inset-0" />
<div
v-if="phase !== 'connected'"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[13px] text-[#a8a69e]"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[13px]"
:style="{ color: dk.ink2 }"
>
<template v-if="phase === 'creating' || phase === 'connecting'">
<NSpin size="small" />
+1 -1
View File
@@ -23,7 +23,7 @@ function retry() {
<div class="mt-1 font-serif text-[27px] font-semibold">慌什么,面板还在</div>
<div class="text-[13.5px] text-ink-2">触发全局限速保护,稍等片刻自动恢复</div>
<div class="mt-5 w-[400px] max-w-full rounded-xl border border-line bg-card px-[18px] py-[15px] text-left shadow-[0_8px_26px_rgba(20,20,19,.08)]">
<div class="mt-5 w-[400px] max-w-full rounded-lg border border-line bg-card px-[18px] py-[15px] text-left shadow-overlay">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-ink-2">当前窗口请求量</span>
<span class="mono text-xs font-semibold" style="color: var(--color-warn)">超限</span>