@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 默认启用 IPv6(Oracle 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 默认启用 IPv6(Oracle 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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user