初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { listAiChannels, listAiKeys, listAiModels } from '@/api/aigateway'
|
||||
import AiChannelPanel from '@/components/ai/AiChannelPanel.vue'
|
||||
import AiKeyPanel from '@/components/ai/AiKeyPanel.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const keys = useAsync(listAiKeys)
|
||||
const channels = useAsync(listAiChannels)
|
||||
const models = useAsync(listAiModels)
|
||||
|
||||
const baseUrl = `${window.location.origin}/ai/v1`
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'POST', path: '/chat/completions', note: 'OpenAI · 流式/非流式' },
|
||||
{ method: 'POST', path: '/responses', note: 'OpenAI Responses · 无状态子集' },
|
||||
{ method: 'POST', path: '/messages', note: 'Claude · 流式/非流式' },
|
||||
{ method: 'POST', path: '/embeddings', note: 'OpenAI Embeddings · 向量化' },
|
||||
{ method: 'GET', path: '/models', note: '模型列表(按密钥分组过滤)' },
|
||||
]
|
||||
|
||||
/** 厂商显示名:模型名前缀 → 品牌名 */
|
||||
const VENDOR_LABEL: Record<string, string> = {
|
||||
meta: 'Meta', xai: 'xAI', google: 'Google', openai: 'OpenAI', cohere: 'Cohere',
|
||||
}
|
||||
|
||||
/** 可用模型按厂商分组(取模型名前缀,厂商内按名称排序) */
|
||||
const modelGroups = computed(() => {
|
||||
const byVendor = new Map<string, string[]>()
|
||||
for (const m of models.data.value ?? []) {
|
||||
const vendor = m.id.split('.', 1)[0] || m.owned_by || '其他'
|
||||
const list = byVendor.get(vendor) ?? []
|
||||
list.push(m.id)
|
||||
byVendor.set(vendor, list)
|
||||
}
|
||||
return [...byVendor.entries()]
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.map(([vendor, ids]) => ({ vendor, label: VENDOR_LABEL[vendor] ?? vendor, ids: ids.sort() }))
|
||||
})
|
||||
|
||||
/** 手风琴:同时只展开一个厂商,配合展开区限高控制卡片总高度 */
|
||||
const expandedVendor = ref<string | null>(null)
|
||||
|
||||
function toggleVendor(vendor: string) {
|
||||
expandedVendor.value = expandedVendor.value === vendor ? null : vendor
|
||||
}
|
||||
|
||||
/** 渠道现有分组去重,供密钥弹窗快捷选择 */
|
||||
const groups = computed(() => [...new Set((channels.data.value ?? []).map((c) => c.group).filter(Boolean))])
|
||||
|
||||
async function copyBase() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(baseUrl)
|
||||
message.success('已复制 Base URL')
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyModel(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id)
|
||||
message.success(`已复制:${id}`)
|
||||
} catch {
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function refreshChannels() {
|
||||
void channels.run({ silent: true })
|
||||
void models.run({ silent: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h1 class="page-title">AI 网关</h1>
|
||||
<span class="text-[13px] text-ink-3">
|
||||
把有 Generative AI 配额的租户接入号池,对外提供 OpenAI / Claude 格式的标准接口
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<span class="mono min-w-0 flex-1 truncate">{{ baseUrl }}</span>
|
||||
<NButton size="tiny" quaternary type="primary" @click="copyBase">复制</NButton>
|
||||
</div>
|
||||
<div
|
||||
v-for="ep in endpoints"
|
||||
:key="ep.path"
|
||||
class="flex items-center gap-2 border-b border-line-soft py-2 text-[13px] last:border-b-0"
|
||||
>
|
||||
<span class="w-13 flex-none text-[10.5px] font-bold text-ok">{{ ep.method }}</span>
|
||||
<span class="mono min-w-0 flex-1">{{ ep.path }}</span>
|
||||
<span class="flex-none text-xs text-ink-3">{{ ep.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
鉴权:Authorization: Bearer ‹密钥› 或 x-api-key: ‹密钥› · 两种头都支持,OpenAI / Anthropic SDK 可直接指向 Base URL
|
||||
</div>
|
||||
</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>
|
||||
<span class="text-xs text-ink-3">{{ models.data.value?.length ?? '…' }} 个</span>
|
||||
</div>
|
||||
<div class="px-4.5 py-1.5">
|
||||
<div
|
||||
v-for="g in modelGroups"
|
||||
:key="g.vendor"
|
||||
class="border-b border-line-soft py-1 last:border-b-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2 py-1.5 text-left"
|
||||
@click="toggleVendor(g.vendor)"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 flex-none text-ink-3 transition-transform"
|
||||
:class="expandedVendor === g.vendor ? 'rotate-90' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-medium">{{ g.label }}</span>
|
||||
<span class="text-xs text-ink-3">{{ g.ids.length }} 个</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="expandedVendor === g.vendor"
|
||||
class="flex max-h-36 flex-wrap content-start gap-1.5 overflow-y-auto pb-2 pl-5"
|
||||
>
|
||||
<button
|
||||
v-for="id in g.ids"
|
||||
:key="id"
|
||||
type="button"
|
||||
class="mono cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-[11.5px] hover:border-accent hover:text-accent"
|
||||
:title="`复制 ${id}`"
|
||||
@click="copyModel(id)"
|
||||
>
|
||||
{{ id }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="models.data.value && !models.data.value.length" class="py-2 text-xs text-ink-3">
|
||||
暂无可用模型:请先添加渠道并探测(需租户订阅 GenAI 提供区域)
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
模型由渠道探测 / 同步自动发现;多模态图片输入视模型而定;Cohere 系工具调用为扁平参数有损降级、不支持图片
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AiKeyPanel
|
||||
:keys="keys.data.value ?? []"
|
||||
:loading="keys.loading.value"
|
||||
:groups="groups"
|
||||
@changed="keys.run({ silent: true })"
|
||||
/>
|
||||
|
||||
<AiChannelPanel
|
||||
:channels="channels.data.value ?? []"
|
||||
:loading="channels.loading.value"
|
||||
@changed="refreshChannels"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NIcon,
|
||||
NInputGroup,
|
||||
NInputGroupLabel,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { deleteBootVolume, listBootVolumes } from '@/api/bootVolumes'
|
||||
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import AttachBootVolumeModal from '@/components/storage/AttachBootVolumeModal.vue'
|
||||
import EditBootVolumeModal from '@/components/storage/EditBootVolumeModal.vue'
|
||||
import { fmtTime, shortAd, truncOcid, vpusLabel } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { BootVolume } from '@/types/api'
|
||||
|
||||
interface BvRow extends BootVolume {
|
||||
cfgId: number
|
||||
}
|
||||
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域 */
|
||||
const scope = useScopeStore()
|
||||
const message = useMessage()
|
||||
|
||||
/** 当前作用域租户失联时整页替换占位,不发起资源请求 */
|
||||
const isDead = computed(() => scope.currentConfig?.aliveStatus === 'dead')
|
||||
|
||||
const rows = useAsync<BvRow[]>(async () => {
|
||||
const cfg = scope.currentConfig
|
||||
if (!cfg || cfg.aliveStatus === 'dead') return []
|
||||
try {
|
||||
const list = await listBootVolumes(
|
||||
cfg.id,
|
||||
undefined,
|
||||
scope.compartmentId || undefined,
|
||||
scope.region || undefined,
|
||||
)
|
||||
return (list ?? []).map((item) => ({ ...item, cfgId: cfg.id }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
watch(
|
||||
[() => scope.currentConfig?.id, () => scope.region, () => scope.compartmentId],
|
||||
() => void rows.run(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 挂载 / 分离是异步动作,提交后延时补一次刷新 */
|
||||
function refreshSoon() {
|
||||
void rows.run()
|
||||
setTimeout(() => void rows.run(), 6000)
|
||||
}
|
||||
|
||||
const showEdit = ref(false)
|
||||
const showAttach = ref(false)
|
||||
const current = ref<BvRow | null>(null)
|
||||
|
||||
function openEdit(row: BvRow) {
|
||||
current.value = row
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
function openAttach(row: BvRow) {
|
||||
current.value = row
|
||||
showAttach.value = true
|
||||
}
|
||||
|
||||
async function remove(row: BvRow) {
|
||||
try {
|
||||
await deleteBootVolume(row.cfgId, row.id, scope.region || undefined)
|
||||
message.success('引导卷已删除')
|
||||
void rows.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyImageOcid(row: BvRow) {
|
||||
await navigator.clipboard.writeText(row.imageId)
|
||||
message.success('已复制镜像 OCID')
|
||||
}
|
||||
|
||||
function renderCopyIcon() {
|
||||
return h(
|
||||
'svg',
|
||||
{ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('rect', { x: '9', y: '9', width: '13', height: '13', rx: '2' }),
|
||||
h('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/** 名称行 + 来源镜像行(镜像名展示,按钮复制镜像 OCID) */
|
||||
function renderName(row: BvRow) {
|
||||
const children = [h('div', { class: 'font-medium' }, row.displayName)]
|
||||
if (row.imageId)
|
||||
children.push(
|
||||
h('div', { class: 'mt-px flex min-w-0 items-center gap-0.5 text-xs text-ink-3' }, [
|
||||
h(
|
||||
'span',
|
||||
{ class: 'truncate', title: row.imageName || row.imageId },
|
||||
row.imageName || truncOcid(row.imageId, 20),
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
quaternary: true,
|
||||
'aria-label': '复制镜像 OCID',
|
||||
onClick: () => copyImageOcid(row),
|
||||
},
|
||||
{ icon: () => h(NIcon, { size: 13 }, { default: renderCopyIcon }) },
|
||||
),
|
||||
]),
|
||||
)
|
||||
return h('div', { class: 'min-w-0' }, children)
|
||||
}
|
||||
|
||||
function renderActions(row: BvRow) {
|
||||
const attached = Boolean(row.attachedInstanceId)
|
||||
const gone = row.lifecycleState === 'TERMINATED' || row.lifecycleState === 'TERMINATING'
|
||||
const attachBtn = h(
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
tertiary: true,
|
||||
disabled: gone || attached || row.lifecycleState !== 'AVAILABLE',
|
||||
title: attached ? '已挂载到实例' : undefined,
|
||||
onClick: () => openAttach(row),
|
||||
},
|
||||
{ default: () => '挂载' },
|
||||
)
|
||||
const deleteBtn =
|
||||
attached || gone
|
||||
? h(
|
||||
NButton,
|
||||
{ size: 'tiny', type: 'error', ghost: true, disabled: true, title: attached ? '仅未挂载状态可删除' : undefined },
|
||||
{ default: () => '删除' },
|
||||
)
|
||||
: h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => remove(row) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', type: 'error', ghost: true }, { default: () => '删除' }),
|
||||
default: () => '删除该引导卷?数据不可恢复',
|
||||
},
|
||||
)
|
||||
return h('div', { class: 'flex items-center gap-1.5' }, [
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', tertiary: true, disabled: gone, onClick: () => openEdit(row) },
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
attachBtn,
|
||||
deleteBtn,
|
||||
])
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<BvRow>>(() => [
|
||||
{ title: '引导卷', key: 'displayName', minWidth: 250, render: renderName },
|
||||
{
|
||||
title: 'AD',
|
||||
key: 'availabilityDomain',
|
||||
width: 80,
|
||||
render: (r) => h('span', { class: 'text-[13px]' }, shortAd(r.availabilityDomain)),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
key: 'sizeInGBs',
|
||||
width: 90,
|
||||
render: (r) => h('span', { class: 'tabular-nums' }, `${r.sizeInGBs} GB`),
|
||||
},
|
||||
{
|
||||
title: '性能',
|
||||
key: 'vpusPerGB',
|
||||
width: 120,
|
||||
render: (r) => h('span', { class: 'text-[13px]' }, vpusLabel(r.vpusPerGB)),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'lifecycleState',
|
||||
width: 116,
|
||||
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
|
||||
},
|
||||
{
|
||||
title: '挂载状态',
|
||||
key: 'attached',
|
||||
width: 100,
|
||||
render: (r) =>
|
||||
h(StatusBadge, {
|
||||
kind: r.attachedInstanceId ? 'run' : 'check',
|
||||
label: r.attachedInstanceId ? '已挂载' : '未挂载',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '挂载实例',
|
||||
key: 'attachedInstanceId',
|
||||
minWidth: 150,
|
||||
render: (r) =>
|
||||
r.attachedInstanceId
|
||||
? h(
|
||||
RouterLink,
|
||||
{
|
||||
class: 'text-[13px] font-medium text-accent hover:text-accent-hover',
|
||||
to: {
|
||||
name: 'instance-detail',
|
||||
params: { cfgId: r.cfgId, instanceId: r.attachedInstanceId },
|
||||
query: { region: scope.region || undefined },
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
r.attachedInstanceName || r.displayName.replace(' (Boot Volume)', ''),
|
||||
},
|
||||
)
|
||||
: h('span', { class: 'text-ink-3' }, '—'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'timeCreated',
|
||||
width: 140,
|
||||
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeCreated)),
|
||||
},
|
||||
{ title: '操作', key: 'actions', width: 190, render: renderActions },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h1 class="page-title">引导卷</h1>
|
||||
<span class="text-[13px] text-ink-3">
|
||||
{{ rows.data.value?.length ?? '…' }} 个 ·
|
||||
{{ scope.currentConfig?.alias ?? '…' }}
|
||||
</span>
|
||||
</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>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="rows.data.value ?? []"
|
||||
:loading="rows.loading.value"
|
||||
:scroll-x="1300"
|
||||
:row-key="(r: BvRow) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
引导卷随实例创建产生,不提供单独创建 · 挂载须实例同可用域且处于 STOPPED · 仅未挂载状态可删除
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
<EditBootVolumeModal
|
||||
v-model:show="showEdit"
|
||||
:cfg-id="current?.cfgId ?? null"
|
||||
:volume="current"
|
||||
:region="scope.region || undefined"
|
||||
@updated="rows.run()"
|
||||
/>
|
||||
<AttachBootVolumeModal
|
||||
v-model:show="showAttach"
|
||||
:cfg-id="current?.cfgId ?? null"
|
||||
:volume="current"
|
||||
:region="scope.region || undefined"
|
||||
@attached="refreshSoon()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,766 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDropdown,
|
||||
NPopconfirm,
|
||||
NSpin,
|
||||
useDialog,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getBootVolume } from '@/api/bootVolumes'
|
||||
import { getConfig } from '@/api/configs'
|
||||
import {
|
||||
changePublicIp,
|
||||
deleteConsoleConnection,
|
||||
detachBootVolume,
|
||||
detachVolume,
|
||||
getImage,
|
||||
getInstance,
|
||||
instanceAction,
|
||||
listBootVolumeAttachments,
|
||||
listConsoleConnections,
|
||||
listVolumeAttachments,
|
||||
terminateInstance,
|
||||
updateInstance,
|
||||
} from '@/api/instances'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import AttachVolumeModal from '@/components/instance/AttachVolumeModal.vue'
|
||||
import ConsoleConnModal from '@/components/instance/ConsoleConnModal.vue'
|
||||
import EditBootVolumeModal from '@/components/instance/EditBootVolumeModal.vue'
|
||||
import InstanceTrafficPanel from '@/components/instance/InstanceTrafficPanel.vue'
|
||||
import ReplaceBootVolumeModal from '@/components/instance/ReplaceBootVolumeModal.vue'
|
||||
import ResizeModal from '@/components/instance/ResizeModal.vue'
|
||||
import VnicPanel from '@/components/instance/VnicPanel.vue'
|
||||
import { useConsoleStore } from '@/stores/console'
|
||||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
import OcidText from '@/components/OcidText.vue'
|
||||
import RenameModal from '@/components/RenameModal.vue'
|
||||
import { fmtTime, shortAd, truncOcid, vpusLabel } from '@/composables/useFormat'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import {
|
||||
ATTACHMENT_TRANSIENT_STATES,
|
||||
INSTANCE_TRANSIENT_STATES,
|
||||
useTransientPoll,
|
||||
} from '@/composables/useTransientPoll'
|
||||
import type { BootVolume, ConsoleConnection, PowerAction, VolumeAttachment } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
const cfgId = computed(() => Number(route.params.cfgId))
|
||||
const instanceId = computed(() => String(route.params.instanceId))
|
||||
/** 实例所在区域:列表页跳转时经 query 传入,缺失时回退实例对象上的短名(后端已归一化) */
|
||||
const region = computed(
|
||||
() => (route.query.region as string) || instance.data.value?.region || undefined,
|
||||
)
|
||||
|
||||
const config = useAsync(() => getConfig(cfgId.value))
|
||||
const instance = useAsync(() =>
|
||||
getInstance(cfgId.value, instanceId.value, (route.query.region as string) || undefined),
|
||||
)
|
||||
const bvAtts = useAsync(() =>
|
||||
listBootVolumeAttachments(cfgId.value, instanceId.value, region.value),
|
||||
)
|
||||
const volAtts = useAsync(() => listVolumeAttachments(cfgId.value, instanceId.value, region.value))
|
||||
const consoles = useAsync(() =>
|
||||
listConsoleConnections(cfgId.value, instanceId.value, region.value),
|
||||
)
|
||||
|
||||
/** 按 imageId 补镜像名称,查询失败时回退 OCID 展示 */
|
||||
const image = useAsync(
|
||||
() => {
|
||||
const inst = instance.data.value
|
||||
if (!inst?.imageId) return Promise.resolve(null)
|
||||
return getImage(cfgId.value, inst.imageId, inst.region).catch(() => null)
|
||||
},
|
||||
false,
|
||||
)
|
||||
watch(
|
||||
() => instance.data.value?.imageId,
|
||||
(v) => {
|
||||
if (v) void image.run()
|
||||
},
|
||||
)
|
||||
|
||||
/** 引导卷挂载补齐卷详情(名称 / 大小 / VPU),支持就地调整 */
|
||||
const bvDetails = useAsync<BootVolume[]>(async () => {
|
||||
const atts = bvAtts.data.value ?? []
|
||||
const list = await Promise.all(
|
||||
atts.map((a) => getBootVolume(cfgId.value, a.bootVolumeId, region.value).catch(() => null)),
|
||||
)
|
||||
return list.filter((b): b is BootVolume => b !== null)
|
||||
}, false)
|
||||
watch(
|
||||
() => bvAtts.data.value,
|
||||
() => void bvDetails.run(),
|
||||
)
|
||||
|
||||
const bvById = computed(() => new Map((bvDetails.data.value ?? []).map((b) => [b.id, b])))
|
||||
|
||||
// 电源 / 挂载操作提交后 OCI 状态异步迁移:过渡态期间每 5 秒静默刷新直到稳态
|
||||
useTransientPoll(
|
||||
() => instance.data.value,
|
||||
(v) => !!v && INSTANCE_TRANSIENT_STATES.includes(v.lifecycleState),
|
||||
() => void instance.run({ silent: true }),
|
||||
)
|
||||
useTransientPoll(
|
||||
() => bvAtts.data.value,
|
||||
(list) => !!list?.some((a) => ATTACHMENT_TRANSIENT_STATES.includes(a.lifecycleState)),
|
||||
() => void bvAtts.run({ silent: true }),
|
||||
)
|
||||
useTransientPoll(
|
||||
() => volAtts.data.value,
|
||||
(list) => !!list?.some((a) => ATTACHMENT_TRANSIENT_STATES.includes(a.lifecycleState)),
|
||||
() => void volAtts.run({ silent: true }),
|
||||
)
|
||||
useTransientPoll(
|
||||
() => consoles.data.value,
|
||||
(list) => !!list?.some((c) => ['CREATING', 'DELETING'].includes(c.lifecycleState)),
|
||||
() => void consoles.run({ silent: true }),
|
||||
)
|
||||
|
||||
const isStopped = computed(() => instance.data.value?.lifecycleState === 'STOPPED')
|
||||
/** 终止 / 终止中:一切变更类操作不可用 */
|
||||
const isEnded = computed(() =>
|
||||
['TERMINATED', 'TERMINATING'].includes(instance.data.value?.lifecycleState ?? ''),
|
||||
)
|
||||
const attachedVolumeIds = computed(() => (volAtts.data.value ?? []).map((a) => a.volumeId))
|
||||
|
||||
/** 创建时若选了随机密码,密码写在 TAG RootPassword 中,这里回显 */
|
||||
const rootPassword = computed(() => instance.data.value?.freeformTags?.RootPassword ?? '')
|
||||
|
||||
async function copyRootPassword() {
|
||||
await navigator.clipboard.writeText(rootPassword.value)
|
||||
message.success('已复制 ROOT 密码')
|
||||
}
|
||||
|
||||
async function copyImageOcid() {
|
||||
await navigator.clipboard.writeText(instance.data.value?.imageId ?? '')
|
||||
message.success('已复制镜像 OCID')
|
||||
}
|
||||
|
||||
const showRename = ref(false)
|
||||
const renaming = ref(false)
|
||||
const showResize = ref(false)
|
||||
const showConsole = ref(false)
|
||||
const showReplaceBv = ref(false)
|
||||
const showAttachVol = ref(false)
|
||||
const showEditBv = ref(false)
|
||||
const editingBv = ref<BootVolume | null>(null)
|
||||
|
||||
function openEditBv(bv: BootVolume) {
|
||||
editingBv.value = bv
|
||||
showEditBv.value = true
|
||||
}
|
||||
|
||||
const consoleStore = useConsoleStore()
|
||||
|
||||
/** VNC 与串行共用实例唯一的控制台连接资源(创建时互相清理),同时只开一个 */
|
||||
function openVnc() {
|
||||
consoleStore.close()
|
||||
const href = router.resolve({
|
||||
name: 'vnc-console',
|
||||
params: { cfgId: cfgId.value, instanceId: instanceId.value },
|
||||
query: { region: region.value, name: instance.data.value?.displayName },
|
||||
}).href
|
||||
window.open(href, '_blank')
|
||||
}
|
||||
|
||||
function openSerial() {
|
||||
void consoleStore.openSerial({
|
||||
cfgId: cfgId.value,
|
||||
instanceId: instanceId.value,
|
||||
region: region.value,
|
||||
instanceName: instance.data.value?.displayName,
|
||||
rootPassword: rootPassword.value,
|
||||
})
|
||||
}
|
||||
|
||||
async function doRename(name: string) {
|
||||
renaming.value = true
|
||||
try {
|
||||
await updateInstance(cfgId.value, instanceId.value, { displayName: name }, region.value)
|
||||
message.success('已重命名')
|
||||
showRename.value = false
|
||||
void instance.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重命名失败')
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
try {
|
||||
await fn()
|
||||
message.success(ok)
|
||||
void instance.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 换 IP 请求期间的按钮 loading,操作发出立即有反馈
|
||||
const changeIpBusy = ref(false)
|
||||
|
||||
function power(action: PowerAction) {
|
||||
void act(
|
||||
() => instanceAction(cfgId.value, instanceId.value, action, region.value),
|
||||
`${action} 已提交`,
|
||||
)
|
||||
}
|
||||
|
||||
const moreOptions = [
|
||||
{ label: 'SOFTSTOP(系统内关机)', key: 'SOFTSTOP' },
|
||||
{ label: 'SOFTRESET(系统内重启)', key: 'SOFTRESET' },
|
||||
]
|
||||
|
||||
function terminate() {
|
||||
dialog.warning({
|
||||
title: '终止实例',
|
||||
content: '终止后实例不可恢复。是否同时保留引导卷?',
|
||||
positiveText: '终止并删除引导卷',
|
||||
negativeText: '终止但保留引导卷',
|
||||
onPositiveClick: () =>
|
||||
act(
|
||||
() => terminateInstance(cfgId.value, instanceId.value, false, region.value),
|
||||
'终止请求已提交',
|
||||
),
|
||||
onNegativeClick: () =>
|
||||
act(
|
||||
() => terminateInstance(cfgId.value, instanceId.value, true, region.value),
|
||||
'终止请求已提交(保留引导卷)',
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async function doChangeIp() {
|
||||
changeIpBusy.value = true
|
||||
await act(() => changePublicIp(cfgId.value, instanceId.value, region.value), '公网 IP 已更换')
|
||||
changeIpBusy.value = false
|
||||
}
|
||||
|
||||
const volColumns: DataTableColumns<VolumeAttachment> = [
|
||||
{
|
||||
title: '块卷',
|
||||
key: 'volumeName',
|
||||
minWidth: 170,
|
||||
render: (r) =>
|
||||
h('div', { class: 'min-w-0' }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'font-medium' },
|
||||
r.volumeName || r.displayName || truncOcid(r.volumeId, 18),
|
||||
),
|
||||
r.volumeId.includes('.bootvolume.')
|
||||
? h('div', { class: 'text-xs text-ink-3 mt-px' }, '引导卷 · 作数据卷附加')
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '设备路径',
|
||||
key: 'device',
|
||||
minWidth: 200,
|
||||
render: (r) => h('span', { class: 'mono' }, r.device || '—'),
|
||||
},
|
||||
{
|
||||
title: '读写',
|
||||
key: 'isReadOnly',
|
||||
width: 80,
|
||||
render: (r) => h('span', { class: 'text-[13px]' }, r.isReadOnly ? '只读' : '读写'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'lifecycleState',
|
||||
width: 120,
|
||||
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (r) =>
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () =>
|
||||
act(() => detachVolume(cfgId.value, r.id, region.value), '分离请求已提交'),
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '分离' }),
|
||||
default: () => '分离该块卷?OS 内请先卸载文件系统',
|
||||
},
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
/** DELETED 是 OCI 保留的终态记录,不可操作也无展示价值,直接过滤 */
|
||||
const liveConsoles = computed(() =>
|
||||
(consoles.data.value ?? []).filter((c) => c.lifecycleState !== 'DELETED'),
|
||||
)
|
||||
|
||||
const deletingConn = ref('')
|
||||
|
||||
async function doDeleteConsoleConn(id: string) {
|
||||
deletingConn.value = id
|
||||
try {
|
||||
await deleteConsoleConnection(cfgId.value, id, region.value)
|
||||
message.success('删除请求已提交')
|
||||
void consoles.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
deletingConn.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 连接串行:类型标签 + 截断命令 + 行内复制,避免整串撑爆表格 */
|
||||
function connStringRow(label: string, value: string): ReturnType<typeof h> {
|
||||
return h('div', { class: 'flex min-w-0 max-w-[640px] items-center gap-1.5' }, [
|
||||
h('span', { class: 'shrink-0 rounded bg-wash px-1.5 text-[11px] leading-5 text-ink-2' }, label),
|
||||
h('code', { class: 'mono min-w-0 flex-1 truncate text-xs', title: value }, value),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
quaternary: true,
|
||||
onClick: async () => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
message.success(`已复制${label}连接串`)
|
||||
},
|
||||
},
|
||||
{ default: () => '复制' },
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
{
|
||||
title: '状态',
|
||||
key: 'lifecycleState',
|
||||
width: 110,
|
||||
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
|
||||
},
|
||||
{
|
||||
title: '连接串',
|
||||
key: 'connectionString',
|
||||
minWidth: 320,
|
||||
render: (r) =>
|
||||
h('div', { class: 'flex min-w-0 flex-col gap-0.5' }, [
|
||||
connStringRow('串口', r.connectionString),
|
||||
connStringRow('VNC', r.vncConnectionString),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (r) =>
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => doDeleteConsoleConn(r.id) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
quaternary: true,
|
||||
type: 'error',
|
||||
loading: deletingConn.value === r.id,
|
||||
disabled: deletingConn.value !== '' && deletingConn.value !== r.id,
|
||||
},
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
default: () => '删除该控制台连接?',
|
||||
},
|
||||
),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div
|
||||
v-if="instance.loading.value && !instance.data.value"
|
||||
class="panel flex items-center justify-center py-20"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div v-if="instance.data.value" class="panel px-5 py-4">
|
||||
<RouterLink
|
||||
class="mb-2 inline-flex items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
|
||||
:to="{ name: 'instances' }"
|
||||
>
|
||||
← 返回实例列表
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="page-title">{{ instance.data.value.displayName }}</h1>
|
||||
<LifecycleBadge :state="instance.data.value.lifecycleState" />
|
||||
<div class="flex-1" />
|
||||
<NButton v-if="isStopped" size="small" :disabled="isEnded" @click="power('START')">
|
||||
启动
|
||||
</NButton>
|
||||
<NButton v-else size="small" :disabled="isEnded" @click="power('STOP')">停止</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="power('RESET')">重启</NButton>
|
||||
<NButton size="small" type="error" ghost :disabled="isEnded" @click="terminate">
|
||||
终止
|
||||
</NButton>
|
||||
<NDropdown
|
||||
:options="moreOptions"
|
||||
:disabled="isEnded"
|
||||
@select="(key: string) => power(key as PowerAction)"
|
||||
>
|
||||
<NButton size="small" quaternary :disabled="isEnded">⋯</NButton>
|
||||
</NDropdown>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-5 gap-4 max-lg:grid-cols-3 max-md:grid-cols-1">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">租户</div>
|
||||
<div class="mt-0.5 truncate text-[13px]" :title="config.data.value?.tenancyName">
|
||||
{{ config.data.value?.tenancyName || config.data.value?.alias || '…' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">区域 / AD</div>
|
||||
<div class="mt-0.5 truncate text-[13px]">
|
||||
{{ regionAlias(instance.data.value.region) }} ·
|
||||
{{ shortAd(instance.data.value.availabilityDomain) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">Shape</div>
|
||||
<div class="mono mt-0.5 truncate text-[13px]">{{ instance.data.value.shape }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">规格</div>
|
||||
<div class="mt-0.5 text-[13px]">
|
||||
{{ instance.data.value.ocpus }} OCPU · {{ instance.data.value.memoryInGBs }} GB
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">创建时间</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ fmtTime(instance.data.value.timeCreated) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<!-- 基本信息 -->
|
||||
<div v-if="instance.data.value" 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">基本信息</div>
|
||||
<NButton size="tiny" quaternary :disabled="isEnded" @click="showRename = true">
|
||||
重命名
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col px-4.5 py-1.5">
|
||||
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">OCID</div>
|
||||
<div class="min-w-0 flex-1"><OcidText :ocid="instance.data.value.id" /></div>
|
||||
</div>
|
||||
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">镜像</div>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span class="truncate text-[13px]" :title="image.data.value?.displayName">
|
||||
{{ image.data.value?.displayName ?? (image.loading.value ? '…' : '未知镜像') }}
|
||||
</span>
|
||||
<NButton size="tiny" quaternary type="primary" @click="copyImageOcid">复制</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="rootPassword"
|
||||
class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5"
|
||||
>
|
||||
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">ROOT 密码</div>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<code class="mono truncate text-[13px]" :title="rootPassword">{{ rootPassword }}</code>
|
||||
<NButton size="tiny" quaternary type="primary" @click="copyRootPassword">
|
||||
复制
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">规格调整</div>
|
||||
<div class="min-w-0 flex-1 text-[13px]">
|
||||
{{ instance.data.value.ocpus }} OCPU · {{ instance.data.value.memoryInGBs }} GB
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
:disabled="isEnded"
|
||||
@click="showResize = true"
|
||||
>
|
||||
调整
|
||||
</NButton>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
可更换 shape 或调整 Flex 规格 · 运行中调整会自动重启
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络 -->
|
||||
<div v-if="instance.data.value" class="panel">
|
||||
<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">
|
||||
主网卡地址快捷视图;全部网卡与各卡 IPv6 管理见下方「网卡(VNIC)」
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col px-4.5 py-1.5">
|
||||
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">私有 IP</div>
|
||||
<div class="mono min-w-0 flex-1">{{ instance.data.value.privateIp || '—' }}</div>
|
||||
</div>
|
||||
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">IPv6</div>
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-for="addr in instance.data.value.ipv6Addresses"
|
||||
:key="addr"
|
||||
class="mono inline-flex max-w-full items-center rounded border border-line bg-white px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
<span class="truncate">{{ addr }}</span>
|
||||
</span>
|
||||
<span v-if="!instance.data.value.ipv6Addresses?.length" class="text-[13px]">—</span>
|
||||
<div class="w-full text-xs text-ink-3">
|
||||
主网卡 IPv6 · 添加 / 删除在下方网卡(VNIC)区块按卡操作
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">子网</div>
|
||||
<div class="min-w-0 flex-1"><OcidText :ocid="instance.data.value.subnetId" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网卡(VNIC):每卡一主行 + IPv6 副行;变化后刷实例(主网卡快捷视图跟随) -->
|
||||
<VnicPanel
|
||||
v-if="instance.data.value"
|
||||
:cfg-id="cfgId"
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
:disabled="isEnded"
|
||||
@changed="instance.run({ silent: true })"
|
||||
/>
|
||||
|
||||
<!-- 引导卷 -->
|
||||
<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">引导卷</div>
|
||||
<NButton
|
||||
size="small"
|
||||
:disabled="!isStopped || isEnded"
|
||||
:title="isStopped ? '' : '替换引导卷须实例处于 STOPPED'"
|
||||
@click="showReplaceBv = true"
|
||||
>
|
||||
替换引导卷
|
||||
</NButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="(bvAtts.loading.value || bvDetails.loading.value) && !bvDetails.data.value"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else class="px-4.5 py-1.5">
|
||||
<div
|
||||
v-for="att in bvAtts.data.value ?? []"
|
||||
:key="att.id"
|
||||
class="flex flex-wrap items-center gap-3 border-b border-line-soft py-2.5 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-ink">
|
||||
{{ bvById.get(att.bootVolumeId)?.displayName ?? truncOcid(att.bootVolumeId, 26) }}
|
||||
</div>
|
||||
<div class="text-xs text-ink-3">
|
||||
<template v-if="bvById.get(att.bootVolumeId)">
|
||||
{{ bvById.get(att.bootVolumeId)!.sizeInGBs }} GB · VPU
|
||||
{{ vpusLabel(bvById.get(att.bootVolumeId)!.vpusPerGB) }}
|
||||
</template>
|
||||
<span v-else class="mono">{{ truncOcid(att.bootVolumeId, 26) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<LifecycleBadge :state="att.lifecycleState" />
|
||||
<div class="flex-1" />
|
||||
<NButton
|
||||
v-if="bvById.get(att.bootVolumeId)"
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
:disabled="isEnded"
|
||||
@click="openEditBv(bvById.get(att.bootVolumeId)!)"
|
||||
>
|
||||
调整大小 / VPU
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
@positive-click="act(() => detachBootVolume(cfgId, att.id, region), '分离请求已提交')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary :disabled="!isStopped || isEnded">分离</NButton>
|
||||
</template>
|
||||
分离引导卷?实例将无法启动直到挂载新引导卷
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<div
|
||||
v-if="!(bvAtts.data.value ?? []).length"
|
||||
class="py-6 text-center text-[13px] text-ink-3"
|
||||
>
|
||||
无挂载引导卷
|
||||
</div>
|
||||
</div>
|
||||
<FootNote>
|
||||
大小仅支持扩容(在线生效,OS 内需扩展文件系统)· 挂载 / 分离 / 替换要求实例处于 STOPPED
|
||||
</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">块存储卷</div>
|
||||
<NButton size="small" type="primary" :disabled="isEnded" @click="showAttachVol = true">
|
||||
附加块卷
|
||||
</NButton>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="volColumns"
|
||||
:data="volAtts.data.value ?? []"
|
||||
:loading="volAtts.loading.value"
|
||||
:scroll-x="700"
|
||||
:row-key="(r: VolumeAttachment) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
半虚拟化附加,运行中实例可热插拔;附加后仍需在实例 OS 内分区挂载文件系统
|
||||
</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">控制台连接(VNC / 串口)</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :disabled="isEnded" @click="openSerial">
|
||||
串行终端
|
||||
</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="openVnc">网页 VNC</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="showConsole = true">创建连接</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="consoleColumns"
|
||||
:data="liveConsoles"
|
||||
:loading="consoles.loading.value"
|
||||
:scroll-x="640"
|
||||
:row-key="(r: ConsoleConnection) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
串行终端 / 网页 VNC 在浏览器内直连(创建会话时自动清理旧连接,二者同时只能开一个)·
|
||||
「创建连接」用于本地 ssh / VNC 客户端:公钥仅支持 RSA,串口直接执行串口连接串,VNC
|
||||
执行后客户端连 localhost:5900 · 串口登录需实例本地账户密码
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
<!-- 流量 -->
|
||||
<InstanceTrafficPanel
|
||||
v-if="instance.data.value"
|
||||
:cfg-id="cfgId"
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
/>
|
||||
|
||||
<RenameModal
|
||||
v-model:show="showRename"
|
||||
title="重命名实例"
|
||||
:current="instance.data.value?.displayName ?? ''"
|
||||
:submitting="renaming"
|
||||
@submit="doRename"
|
||||
/>
|
||||
<ResizeModal
|
||||
v-if="instance.data.value"
|
||||
v-model:show="showResize"
|
||||
:cfg-id="cfgId"
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
:shape="instance.data.value.shape"
|
||||
:ocpus="instance.data.value.ocpus"
|
||||
:memory-in-g-bs="instance.data.value.memoryInGBs"
|
||||
@resized="instance.run()"
|
||||
/>
|
||||
<ConsoleConnModal
|
||||
v-model:show="showConsole"
|
||||
:cfg-id="cfgId"
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
@created="consoles.run()"
|
||||
/>
|
||||
<ReplaceBootVolumeModal
|
||||
v-if="instance.data.value"
|
||||
v-model:show="showReplaceBv"
|
||||
:cfg-id="cfgId"
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
:availability-domain="instance.data.value.availabilityDomain"
|
||||
@replaced="bvAtts.run()"
|
||||
/>
|
||||
<AttachVolumeModal
|
||||
v-if="instance.data.value"
|
||||
v-model:show="showAttachVol"
|
||||
:cfg-id="cfgId"
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
:availability-domain="instance.data.value.availabilityDomain"
|
||||
:attached-volume-ids="attachedVolumeIds"
|
||||
@attached="volAtts.run()"
|
||||
/>
|
||||
<EditBootVolumeModal
|
||||
v-model:show="showEditBv"
|
||||
:cfg-id="cfgId"
|
||||
:region="region"
|
||||
:boot-volume="editingBv"
|
||||
@updated="bvDetails.run()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NInputGroup,
|
||||
NInputGroupLabel,
|
||||
NSelect,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { instanceAction, listInstances } from '@/api/instances'
|
||||
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
||||
import CreateInstanceModal from '@/components/instance/CreateInstanceModal.vue'
|
||||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { shortAd } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { INSTANCE_TRANSIENT_STATES, useTransientPoll } from '@/composables/useTransientPoll'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { Instance, PowerAction } from '@/types/api'
|
||||
|
||||
interface InstanceRow extends Instance {
|
||||
cfgId: number
|
||||
cfgAlias: string
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const { alias } = useRegionAlias()
|
||||
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域,页内只做区间切换与搜索 */
|
||||
const scope = useScopeStore()
|
||||
|
||||
const keyword = ref('')
|
||||
const showCreate = ref(false)
|
||||
|
||||
const rows = useAsync<InstanceRow[]>(async () => {
|
||||
const cfg = scope.currentConfig
|
||||
if (!cfg || cfg.aliveStatus === 'dead') return []
|
||||
const compartmentId = scope.compartmentId || undefined
|
||||
try {
|
||||
const list = await listInstances(cfg.id, scope.region || undefined, compartmentId)
|
||||
return list.map((item) => ({ ...item, cfgId: cfg.id, cfgAlias: cfg.alias }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
/** 当前作用域租户失联时整页替换占位,不发起资源请求 */
|
||||
const isDead = computed(() => scope.currentConfig?.aliveStatus === 'dead')
|
||||
|
||||
// 同一 flush 内租户与区域先后变化只触发一次查询,避免旧区域请求覆盖新结果
|
||||
watch(
|
||||
[() => scope.currentConfig?.id, () => scope.region, () => scope.compartmentId],
|
||||
() => void rows.run(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 创建 / 电源操作后存在过渡态行时每 5 秒静默刷新(不闪 loading),直到全部进入稳态
|
||||
useTransientPoll(
|
||||
() => rows.data.value,
|
||||
(list) => !!list?.some((r) => INSTANCE_TRANSIENT_STATES.includes(r.lifecycleState)),
|
||||
() => void rows.run({ silent: true }),
|
||||
)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
return (rows.data.value ?? []).filter((r) => {
|
||||
if (!kw) return true
|
||||
return [r.displayName, r.id, r.publicIp, r.privateIp].some((v) =>
|
||||
v?.toLowerCase().includes(kw),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function isEnded(row: InstanceRow): boolean {
|
||||
return row.lifecycleState === 'TERMINATED' || row.lifecycleState === 'TERMINATING'
|
||||
}
|
||||
|
||||
async function power(row: InstanceRow, action: PowerAction) {
|
||||
try {
|
||||
await instanceAction(row.cfgId, row.id, action, scope.region || undefined)
|
||||
message.success(`${action} 已提交:${row.displayName}`)
|
||||
void rows.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<InstanceRow>>(() => [
|
||||
{
|
||||
title: '实例',
|
||||
key: 'displayName',
|
||||
minWidth: 180,
|
||||
render: (row) =>
|
||||
h('div', { class: 'min-w-0' }, [
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
class: 'font-medium text-ink hover:text-accent',
|
||||
to: {
|
||||
name: 'instance-detail',
|
||||
params: { cfgId: row.cfgId, instanceId: row.id },
|
||||
query: { region: scope.region || undefined },
|
||||
},
|
||||
},
|
||||
{ default: () => row.displayName },
|
||||
),
|
||||
h('div', { class: 'text-xs text-ink-3 mt-px' }, row.cfgAlias),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: 'Shape',
|
||||
key: 'shape',
|
||||
minWidth: 190,
|
||||
render: (row) =>
|
||||
h('div', [
|
||||
h('div', { class: 'text-[13px]' }, row.shape),
|
||||
h('div', { class: 'text-xs text-ink-3 mt-px' }, `${row.ocpus} OCPU · ${row.memoryInGBs} GB`),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '区域 / AD',
|
||||
key: 'region',
|
||||
minWidth: 170,
|
||||
render: (row) =>
|
||||
h('div', [
|
||||
h('div', { class: 'text-[13px]' }, alias(row.region)),
|
||||
h('div', { class: 'text-xs text-ink-3 mt-px' }, shortAd(row.availabilityDomain)),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '公网 IP',
|
||||
key: 'publicIp',
|
||||
minWidth: 130,
|
||||
render: (row) => h('span', { class: 'mono' }, row.publicIp || '—'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'lifecycleState',
|
||||
minWidth: 130,
|
||||
render: (row) => h(LifecycleBadge, { state: row.lifecycleState }),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (row) =>
|
||||
h('div', { class: 'flex items-center gap-1' }, [
|
||||
row.lifecycleState === 'STOPPED'
|
||||
? h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'START') },
|
||||
{ default: () => '启动' },
|
||||
)
|
||||
: h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'STOP') },
|
||||
{ default: () => '停止' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'RESET') },
|
||||
{ default: () => '重启' },
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<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">
|
||||
{{ rows.data.value?.length ?? '…' }} 台 ·
|
||||
{{ scope.currentConfig?.alias ?? '…' }}
|
||||
</span>
|
||||
</div>
|
||||
<NButton v-if="!isDead" size="small" type="primary" @click="showCreate = true">
|
||||
创建实例
|
||||
</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>
|
||||
<NSelect
|
||||
v-model:value="scope.compartmentId"
|
||||
:options="scope.compOptions"
|
||||
: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">
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
placeholder="搜索实例名 / OCID / IP"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="filtered"
|
||||
:loading="rows.loading.value"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
:scroll-x="960"
|
||||
:row-key="(r: InstanceRow) => r.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateInstanceModal
|
||||
v-model:show="showCreate"
|
||||
:cfg-id="scope.currentConfig?.id ?? null"
|
||||
:compartment-id="scope.compartmentId"
|
||||
:region="scope.region"
|
||||
@created="rows.run()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getOauthAuthorizeUrl, getOauthProviders, login } from '@/api/auth'
|
||||
import { ApiError } from '@/api/request'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
|
||||
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { OauthProviderInfo } from '@/types/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const totpCode = ref('')
|
||||
// 密码通过但账号启用了两步验证:展示验证码输入
|
||||
const totpRequired = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
// 可登录的外部方式(已禁用的后端不返回);未配置时不显示分隔区
|
||||
const providers = ref<OauthProviderInfo[]>([])
|
||||
// 密码登录已禁用(设置里开启且存在可用外部身份):隐藏用户名密码表单
|
||||
const passwordLoginDisabled = ref(false)
|
||||
|
||||
// 左侧品牌区功能徽章:各自不同周期/幅度/相位上下浮动(不规律观感)
|
||||
const featureChips = [
|
||||
{ label: '实例测活', dot: 'var(--color-ok)', style: '--bd:5.7s;--bdel:-1.3s;--bamp:-7px' },
|
||||
{ label: '自动抢机', dot: 'var(--color-accent)', style: '--bd:7.4s;--bdel:-4.1s;--bamp:-5px' },
|
||||
{ label: 'AI 网关', dot: 'var(--color-info)', style: '--bd:6.2s;--bdel:-2.6s;--bamp:-9px' },
|
||||
{ label: '日志回传', dot: 'var(--color-warn)', style: '--bd:8.3s;--bdel:-0.8s;--bamp:-6px' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
consumeOauthCallback()
|
||||
try {
|
||||
const r = await getOauthProviders()
|
||||
providers.value = r.providers
|
||||
passwordLoginDisabled.value = r.passwordLoginDisabled && r.providers.length > 0
|
||||
} catch {
|
||||
/* 登录页容忍 provider 列表加载失败 */
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理 OAuth 回跳:fragment 中的 token 直接建会话,query 中的错误展示后清除 */
|
||||
function consumeOauthCallback() {
|
||||
const hash = window.location.hash
|
||||
if (hash.startsWith('#oauthToken=')) {
|
||||
const token = decodeURIComponent(hash.slice('#oauthToken='.length))
|
||||
history.replaceState(null, '', window.location.pathname)
|
||||
// OAuth 登录未返回过期时间,按后端 JWT 24h 计
|
||||
auth.setSession(token, new Date(Date.now() + 24 * 3600e3).toISOString())
|
||||
void router.push({ name: 'overview' })
|
||||
return
|
||||
}
|
||||
const err = route.query.oauthError
|
||||
if (typeof err === 'string' && err) {
|
||||
errorMsg.value = err
|
||||
void router.replace({ query: {} })
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!username.value || !password.value) {
|
||||
errorMsg.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
if (totpRequired.value && !totpCode.value) {
|
||||
errorMsg.value = '请输入两步验证码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const resp = await login({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
...(totpCode.value ? { totpCode: totpCode.value } : {}),
|
||||
})
|
||||
auth.setSession(resp.token, resp.expiresAt)
|
||||
const redirect = route.query.redirect
|
||||
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 428) {
|
||||
totpRequired.value = true
|
||||
return
|
||||
}
|
||||
errorMsg.value = e instanceof Error ? e.message : '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function oauthLogin(provider: string) {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const { url } = await getOauthAuthorizeUrl(provider, 'login')
|
||||
window.location.href = url
|
||||
} catch (e) {
|
||||
errorMsg.value = e instanceof Error ? e.message : '跳转失败'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex min-h-screen items-center overflow-hidden">
|
||||
<FullPageBackdrop tint="accent" rings="none" />
|
||||
|
||||
<div
|
||||
class="relative z-10 mx-auto flex w-full max-w-[1180px] items-center justify-between gap-12 px-12 max-lg:flex-col max-lg:justify-center max-lg:gap-9 max-lg:px-6 max-lg:py-10"
|
||||
>
|
||||
<!-- 品牌区(双轨道环以品牌内容为中心,与设计稿同心) -->
|
||||
<div class="relative flex max-w-[480px] flex-col gap-7 max-lg:items-center max-lg:gap-5 max-lg:text-center">
|
||||
<div class="pointer-events-none absolute inset-0 max-lg:hidden" aria-hidden="true">
|
||||
<div class="fpb-ring fpb-spin" style="width: 580px; height: 580px; left: calc(50% - 290px); top: calc(50% - 290px)"></div>
|
||||
<div class="fpb-ring fpb-ring-dash" style="width: 430px; height: 430px; left: calc(50% - 215px); top: calc(50% - 215px)"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="floaty flex-none"><AppLogo :size="58" /></span>
|
||||
<span>
|
||||
<span class="block font-serif text-2xl font-semibold">OCI Portal</span>
|
||||
<span class="mt-px block text-[12.5px] text-ink-3">自托管 · Self-hosted</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-serif text-[46px] leading-[1.24] font-semibold max-lg:text-[30px]">
|
||||
批量管理你的<br />Oracle Cloud 租户
|
||||
</div>
|
||||
<div class="flex max-w-[420px] flex-wrap gap-2 max-lg:justify-center">
|
||||
<span v-for="c in featureChips" :key="c.label" class="chip" :style="c.style">
|
||||
<span class="dot" :style="{ background: c.dot }"></span>{{ c.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录卡 -->
|
||||
<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="font-serif text-[19px] font-semibold">欢迎回来</div>
|
||||
|
||||
<div
|
||||
v-if="errorMsg"
|
||||
class="mt-4 flex items-start gap-2 rounded-md border border-err/30 bg-err/10 px-3 py-2 text-[12.5px] text-err"
|
||||
role="alert"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<form v-if="!passwordLoginDisabled" class="mt-5 flex flex-col gap-4" @submit.prevent="submit">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-[13px] font-medium text-ink-2">用户名</span>
|
||||
<NInput v-model:value="username" placeholder="admin" :disabled="loading" />
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-[13px] font-medium text-ink-2">密码</span>
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label v-if="totpRequired" class="flex flex-col gap-1.5">
|
||||
<span class="text-[13px] font-medium text-ink-2">两步验证码</span>
|
||||
<NInput
|
||||
v-model:value="totpCode"
|
||||
placeholder="验证器 App 中的 6 位数字"
|
||||
:maxlength="6"
|
||||
:disabled="loading"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<span class="text-xs text-ink-3">该账号已启用两步验证,请输入动态验证码完成登录</span>
|
||||
</label>
|
||||
|
||||
<NButton type="primary" attr-type="submit" size="large" block :loading="loading">
|
||||
{{ loading ? '验证中…' : '登 录' }}
|
||||
</NButton>
|
||||
</form>
|
||||
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">密码登录已禁用,请使用外部身份继续</div>
|
||||
|
||||
<template v-if="providers.length">
|
||||
<div v-if="!passwordLoginDisabled" class="mt-5 flex items-center gap-2.5 text-[11.5px] text-ink-3">
|
||||
<span class="h-px flex-1 bg-line-soft"></span>
|
||||
或
|
||||
<span class="h-px flex-1 bg-line-soft"></span>
|
||||
</div>
|
||||
<div class="flex" :class="passwordLoginDisabled ? 'mt-6 flex-col gap-2.5' : 'mt-4 gap-2'">
|
||||
<NButton
|
||||
v-for="p in providers"
|
||||
:key="p.provider"
|
||||
:block="passwordLoginDisabled"
|
||||
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
|
||||
:size="passwordLoginDisabled ? 'large' : 'medium'"
|
||||
:disabled="loading"
|
||||
@click="oauthLogin(p.provider)"
|
||||
>
|
||||
<template #icon><OauthProviderIcon :provider="p.provider" /></template>
|
||||
{{ passwordLoginDisabled ? `使用 ${p.displayName} 登录` : p.displayName }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="passwordLoginDisabled" class="mt-5 flex items-center justify-center gap-1.5 text-[11.5px] text-ink-3">
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<rect x="5" y="11" width="14" height="9" rx="2"></rect>
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4"></path>
|
||||
</svg>
|
||||
由管理员在 设置 · 账号安全 中管理
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 999px;
|
||||
background: var(--color-card);
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-2);
|
||||
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.07);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.floaty {
|
||||
animation: login-fl 7s ease-in-out infinite;
|
||||
}
|
||||
.chip {
|
||||
animation: login-bob var(--bd, 6.5s) ease-in-out var(--bdel, 0s) infinite;
|
||||
}
|
||||
@keyframes login-fl {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
@keyframes login-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
32% {
|
||||
transform: translateY(var(--bamp, -6px));
|
||||
}
|
||||
68% {
|
||||
transform: translateY(calc(var(--bamp, -6px) * -0.4));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { NTabPane, NTabs } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AiCallLogPanel from '@/components/logs/AiCallLogPanel.vue'
|
||||
import LogEventPanel from '@/components/logs/LogEventPanel.vue'
|
||||
import SystemLogPanel from '@/components/logs/SystemLogPanel.vue'
|
||||
|
||||
// 日志页以 tab 组织:系统日志(面板自身留痕)、回传日志(OCI 推送事件)与 AI 调用(网关用量)
|
||||
const tab = ref('system')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h1 class="page-title">日志</h1>
|
||||
<span class="text-[13px] text-ink-3">系统操作与云端事件</span>
|
||||
</div>
|
||||
|
||||
<NTabs v-model:value="tab" type="line">
|
||||
<NTabPane name="system" tab="系统日志" />
|
||||
<NTabPane name="relay" tab="回传日志" />
|
||||
<NTabPane name="ai" tab="AI 调用" />
|
||||
</NTabs>
|
||||
|
||||
<SystemLogPanel v-if="tab === 'system'" />
|
||||
<LogEventPanel v-else-if="tab === 'relay'" />
|
||||
<AiCallLogPanel v-else />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
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 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 StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { Vcn } from '@/types/api'
|
||||
|
||||
interface VcnRow extends Vcn {
|
||||
cfgId: number
|
||||
region: string
|
||||
subnetCount: number
|
||||
}
|
||||
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域 */
|
||||
const scope = useScopeStore()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
const showCreate = ref(false)
|
||||
|
||||
/** 当前作用域租户失联时整页替换占位,不发起资源请求 */
|
||||
const isDead = computed(() => scope.currentConfig?.aliveStatus === 'dead')
|
||||
|
||||
const rows = useAsync<VcnRow[]>(async () => {
|
||||
const cfg = scope.currentConfig
|
||||
if (!cfg || cfg.aliveStatus === 'dead') return []
|
||||
const region = scope.region || undefined
|
||||
try {
|
||||
const vcns = await listVcns(cfg.id, region, scope.compartmentId || undefined)
|
||||
return Promise.all(
|
||||
vcns.map(async (vcn) => {
|
||||
// 后端异常路径可能返回 null 而非 [],.catch 拦不住,需再判空
|
||||
const subnets = (await listSubnets(cfg.id, vcn.id, region).catch(() => [])) ?? []
|
||||
return {
|
||||
...vcn,
|
||||
cfgId: cfg.id,
|
||||
region: scope.region || cfg.region,
|
||||
subnetCount: subnets.length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
watch(
|
||||
[() => scope.currentConfig?.id, () => scope.region, () => scope.compartmentId],
|
||||
() => void rows.run(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const columns = computed<DataTableColumns<VcnRow>>(() => [
|
||||
{
|
||||
title: 'VCN',
|
||||
key: 'displayName',
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h('div', { class: 'min-w-0' }, [
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
class: 'font-medium text-ink hover:text-accent',
|
||||
to: {
|
||||
name: 'vcn-detail',
|
||||
params: { cfgId: row.cfgId, vcnId: row.id },
|
||||
query: { region: scope.region || undefined },
|
||||
},
|
||||
},
|
||||
{ default: () => row.displayName },
|
||||
),
|
||||
h('div', { class: 'mono text-xs text-ink-3 mt-px' }, row.dnsLabel),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '区域',
|
||||
key: 'region',
|
||||
minWidth: 150,
|
||||
render: (row) => h('span', { class: 'text-[13px]' }, regionAlias(row.region)),
|
||||
},
|
||||
{
|
||||
title: 'IPv4 CIDR',
|
||||
key: 'cidrBlocks',
|
||||
minWidth: 130,
|
||||
render: (row) => h('span', { class: 'mono' }, (row.cidrBlocks ?? []).join(', ')),
|
||||
},
|
||||
{
|
||||
title: 'IPv6',
|
||||
key: 'ipv6CidrBlocks',
|
||||
minWidth: 120,
|
||||
render: (row) =>
|
||||
row.ipv6CidrBlocks?.length
|
||||
? h(StatusBadge, { kind: 'prov', label: '/56 已分配', dot: false })
|
||||
: h(StatusBadge, { kind: 'check', label: '未启用', dot: false }),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'lifecycleState',
|
||||
width: 120,
|
||||
render: (row) => h(LifecycleBadge, { state: row.lifecycleState }),
|
||||
},
|
||||
{
|
||||
title: '子网',
|
||||
key: 'subnetCount',
|
||||
width: 70,
|
||||
render: (row) => h('span', { class: 'tabular-nums' }, String(row.subnetCount)),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<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">
|
||||
{{ rows.data.value?.length ?? '…' }} 个 VCN ·
|
||||
{{ scope.currentConfig?.alias ?? '…' }}
|
||||
</span>
|
||||
</div>
|
||||
<NButton v-if="!isDead" size="small" type="primary" @click="showCreate = true">
|
||||
创建 VCN
|
||||
</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>
|
||||
<NSelect
|
||||
v-model:value="scope.compartmentId"
|
||||
:options="scope.compOptions"
|
||||
:loading="scope.compartments.loading"
|
||||
:disabled="scope.compDisabled"
|
||||
size="small"
|
||||
/>
|
||||
</NInputGroup>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="rows.data.value ?? []"
|
||||
:loading="rows.loading.value"
|
||||
:scroll-x="920"
|
||||
:row-key="(r: VcnRow) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
创建 VCN 默认启用 IPv6(Oracle GUA /56)并将默认安全列表替换为出入全放行;如需收紧请再更新安全列表
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
<CreateVcnModal
|
||||
v-model:show="showCreate"
|
||||
:cfg-id="scope.currentConfig?.id ?? null"
|
||||
:compartment-id="scope.compartmentId"
|
||||
:region="scope.region"
|
||||
@created="rows.run()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { getOverview, listConfigs } from '@/api/configs'
|
||||
import { listTaskLogs, listTasks } from '@/api/tasks'
|
||||
import CostChart from '@/components/CostChart.vue'
|
||||
import EmptyCard from '@/components/EmptyCard.vue'
|
||||
import { fmtRelative } from '@/composables/useFormat'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { TaskLog } from '@/types/api'
|
||||
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
const configs = useAsync(listConfigs)
|
||||
const overview = useAsync(getOverview)
|
||||
const ov = computed(() => overview.data.value)
|
||||
|
||||
interface LogRow extends TaskLog {
|
||||
taskName: string
|
||||
}
|
||||
|
||||
/** 最近任务执行 = 各任务最新日志按时间合并(最多取 5 条) */
|
||||
const recentLogs = useAsync<LogRow[]>(async () => {
|
||||
const list = await listTasks()
|
||||
const rows = await Promise.all(
|
||||
list.slice(0, 6).map(async (task) => {
|
||||
const logs = await listTaskLogs(task.id, 3)
|
||||
return logs.map((log) => ({ ...log, taskName: task.name }))
|
||||
}),
|
||||
)
|
||||
return rows
|
||||
.flat()
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const regionCount = computed(() => new Set(configs.data.value?.map((c) => c.region)).size)
|
||||
|
||||
const costDaily = computed(() => {
|
||||
const days = ov.value?.cost.days ?? []
|
||||
return {
|
||||
labels: days.map((d) => d.day.slice(5)),
|
||||
values: days.map((d) => +d.amount.toFixed(2)),
|
||||
}
|
||||
})
|
||||
|
||||
const currencySymbol = computed(() => {
|
||||
const currency = ov.value?.cost.currency
|
||||
return currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h1 class="page-title">总览</h1>
|
||||
<span class="text-[13px] text-ink-3">数据来自本地快照与测活记录</span>
|
||||
</div>
|
||||
|
||||
<!-- KPI -->
|
||||
<div class="grid grid-cols-4 gap-3.5 max-lg:grid-cols-2 max-md:grid-cols-1">
|
||||
<div class="panel px-4.5 pt-4 pb-3.5">
|
||||
<div class="text-[13px] text-ink-2">租户总数</div>
|
||||
<div class="mt-1 text-3xl font-semibold tabular-nums">
|
||||
{{ ov?.tenants.total ?? '—' }}
|
||||
</div>
|
||||
<div class="mt-1 text-[12.5px] text-ink-3">覆盖 {{ regionCount }} 个区域</div>
|
||||
</div>
|
||||
|
||||
<div class="panel px-4.5 pt-4 pb-3.5">
|
||||
<div class="text-[13px] text-ink-2">存活租户</div>
|
||||
<div class="mt-1 text-3xl font-semibold tabular-nums">{{ ov?.tenants.alive ?? '—' }}</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-[12.5px] text-ink-3">
|
||||
<span v-if="ov?.tenants.dead" class="h-1.5 w-1.5 rounded-full bg-err" />
|
||||
{{ ov?.tenants.dead ? `${ov.tenants.dead} 个失联待处理` : '全部正常' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ov?.check.hasActiveTask" class="panel px-4.5 pt-4 pb-3.5">
|
||||
<div class="text-[13px] text-ink-2">实例数</div>
|
||||
<div class="mt-1 text-3xl font-semibold tabular-nums">{{ ov.check.instanceCount }}</div>
|
||||
<div class="mt-1 text-[12.5px] text-ink-3">
|
||||
测活快照 · 覆盖 {{ ov.check.coveredConfigs }}/{{ ov.check.totalConfigs }} 租户
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rounded-lg border border-dashed border-line px-4.5 pt-4 pb-3.5">
|
||||
<div class="text-[13px] text-ink-2">实例数</div>
|
||||
<div class="mt-1 text-3xl font-medium text-ink-3">—</div>
|
||||
<div class="mt-1 text-[12.5px] text-ink-3">
|
||||
未配置测活任务 ·
|
||||
<RouterLink class="font-medium text-accent hover:text-accent-hover" :to="{ name: 'tasks' }"
|
||||
>前往创建</RouterLink
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel px-4.5 pt-4 pb-3.5">
|
||||
<div class="text-[13px] text-ink-2">后台任务</div>
|
||||
<div class="mt-1 text-3xl font-semibold tabular-nums">{{ ov?.tasks.total ?? '—' }}</div>
|
||||
<div class="mt-1 text-[12.5px] text-ink-3">
|
||||
抢机 {{ ov?.tasks.snatch ?? 0 }} · 测活 {{ ov?.tasks.healthCheck ?? 0 }} · 成本
|
||||
{{ ov?.tasks.cost ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成本 + 测活状态 -->
|
||||
<div class="grid grid-cols-[2fr_1fr] items-start gap-4 max-lg:grid-cols-1">
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2.5 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="text-sm font-semibold">近 7 天成本</div>
|
||||
<div class="text-[12.5px] text-ink-3">
|
||||
{{ ov?.cost.currency || 'USD' }} · 每日快照 · 覆盖
|
||||
{{ ov?.cost.coveredConfigs ?? 0 }} 个租户,免费号不同步
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="ov?.cost.hasActiveTask && costDaily.labels.length">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5 px-4.5 pt-3.5">
|
||||
<div class="text-[26px] font-semibold tabular-nums">
|
||||
{{ currencySymbol }}{{ (ov?.cost.total ?? 0).toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-[12.5px] text-ink-3">
|
||||
{{ costDaily.labels[0] }} 至 {{ costDaily.labels.at(-1) }} 合计
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 pb-2">
|
||||
<CostChart :labels="costDaily.labels" :values="costDaily.values" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="ov && !ov.cost.hasActiveTask"
|
||||
class="flex flex-col items-center gap-2 px-4.5 py-9 text-center"
|
||||
>
|
||||
<div class="text-sm font-semibold">未配置每日成本任务</div>
|
||||
<div class="max-w-90 text-[12.5px] leading-relaxed text-ink-3">
|
||||
创建成本任务后,每日自动拉取 Usage API 写入本地快照;免费类别租户自动跳过
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'tasks' }">
|
||||
<span
|
||||
class="inline-flex cursor-pointer items-center rounded-md border border-line px-3 py-1.5 text-[13px] font-medium text-ink-2 hover:border-accent hover:text-accent"
|
||||
>
|
||||
前往任务中心
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<EmptyCard
|
||||
v-else-if="!overview.loading.value"
|
||||
title="暂无成本数据"
|
||||
note="成本任务已配置,首次执行后开始累积快照(可到任务中心手动触发一次)"
|
||||
/>
|
||||
</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">租户测活状态</div>
|
||||
<div class="text-[12.5px] text-ink-3">
|
||||
{{ ov?.tenants.alive ?? 0 }}/{{ ov?.tenants.total ?? 0 }} 存活
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4.5 py-1.5">
|
||||
<div
|
||||
v-for="c in configs.data.value ?? []"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-2.5 border-b border-line-soft py-2 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0 truncate text-[13.5px] font-medium">{{ c.alias }}</div>
|
||||
<div class="min-w-0 flex-1 truncate text-[11.5px] text-ink-3">
|
||||
{{ regionAlias(c.region) }}
|
||||
</div>
|
||||
<div class="flex w-14 flex-none items-center gap-1.5 text-[12.5px]">
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="c.aliveStatus === 'alive' ? 'bg-ok' : 'bg-err'"
|
||||
/>
|
||||
{{ c.aliveStatus === 'alive' ? '存活' : '失联' }}
|
||||
</div>
|
||||
<div class="w-18 flex-none text-right text-xs text-ink-3">{{ fmtRelative(c.lastVerifiedAt) }}</div>
|
||||
</div>
|
||||
<div class="py-2.5 text-center">
|
||||
<RouterLink
|
||||
class="text-[13px] font-medium text-accent hover:text-accent-hover"
|
||||
:to="{ name: 'tenants' }"
|
||||
>查看全部租户</RouterLink
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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">最近任务执行</div>
|
||||
<RouterLink
|
||||
class="text-[13px] font-medium text-accent hover:text-accent-hover"
|
||||
:to="{ name: 'tasks' }"
|
||||
>前往任务中心</RouterLink
|
||||
>
|
||||
</div>
|
||||
<div class="px-4.5 py-1.5">
|
||||
<div
|
||||
v-for="log in recentLogs.data.value ?? []"
|
||||
:key="log.id"
|
||||
class="flex items-start gap-2.5 border-b border-line-soft py-2.5 last:border-b-0"
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 flex h-5 w-5 flex-none items-center justify-center rounded-full"
|
||||
:class="log.success ? 'bg-ok/15 text-ok' : 'bg-err/15 text-err'"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path v-if="log.success" d="M20 6 9 17l-5-5" />
|
||||
<template v-else>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</template>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mono text-[12.5px] break-all">{{ log.message }}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
{{ log.taskName }} · {{ fmtRelative(log.createdAt) }} · {{ log.durationMs }}ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyCard
|
||||
v-if="!recentLogs.loading.value && !recentLogs.data.value?.length"
|
||||
title="暂无执行记录"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ProxyPanel from '@/components/proxy/ProxyPanel.vue'
|
||||
|
||||
const panel = ref<InstanceType<typeof ProxyPanel> | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<h1 class="page-title">代理</h1>
|
||||
<span class="text-[13px] text-ink-3">出站代理管理,租户关联后 SDK 请求经代理发出</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<NButton size="small" @click="panel?.openImport()">批量导入</NButton>
|
||||
<NButton size="small" type="primary" @click="panel?.openCreate()">新增代理</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<ProxyPanel ref="panel" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,631 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NSpin, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
getNotifyEvents,
|
||||
getSecuritySetting,
|
||||
getTaskSetting,
|
||||
getTelegramSetting,
|
||||
listNotifyTemplates,
|
||||
testTelegram,
|
||||
updateNotifyEvents,
|
||||
updateSecuritySetting,
|
||||
updateTaskSetting,
|
||||
updateTelegramSetting,
|
||||
} from '@/api/settings'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import AboutTab from '@/components/settings/AboutTab.vue'
|
||||
import AccountSecurityCard from '@/components/settings/AccountSecurityCard.vue'
|
||||
import NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { NotifyEventsSetting, NotifyTemplateItem, SecuritySetting } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
|
||||
const tab = ref('notify')
|
||||
|
||||
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab
|
||||
onMounted(() => {
|
||||
const { oauth, oauthError } = route.query
|
||||
if (oauth !== 'bound' && !oauthError) return
|
||||
tab.value = 'security'
|
||||
if (oauth === 'bound') message.success('外部身份绑定成功')
|
||||
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
|
||||
void router.replace({ query: {} })
|
||||
})
|
||||
|
||||
// ---- 通知管理:按事件类型的推送开关 ----
|
||||
const events = useAsync(getNotifyEvents)
|
||||
const eventForm = ref<NotifyEventsSetting>({
|
||||
taskFail: true,
|
||||
taskRecover: true,
|
||||
snatchSuccess: true,
|
||||
tenantDead: true,
|
||||
taskStop: true,
|
||||
loginLock: true,
|
||||
modelDeprecated: true,
|
||||
logEventInstance: true,
|
||||
logEventIdentity: true,
|
||||
logEventPolicy: true,
|
||||
logEventRegion: true,
|
||||
logEventLogin: true,
|
||||
})
|
||||
const savingEvents = ref(false)
|
||||
|
||||
const eventRows: { key: keyof NotifyEventsSetting; label: string; hint: string }[] = [
|
||||
{ key: 'taskFail', label: '任务失败', hint: '任务由成功转失败时推送(连续失败不重复发)' },
|
||||
{ key: 'taskRecover', label: '任务恢复', hint: '任务由失败恢复正常时推送' },
|
||||
{ key: 'snatchSuccess', label: '抢机成功', hint: '抢机任务达成目标台数时推送' },
|
||||
{ key: 'tenantDead', label: '租户失联', hint: '测活失联集合变化时推送' },
|
||||
{ key: 'taskStop', label: '任务停止', hint: '抢机连续鉴权失败熔断停止时推送' },
|
||||
{ key: 'loginLock', label: '登录锁定', hint: '同一 IP+用户名连续失败达阈值时推送' },
|
||||
{ key: 'modelDeprecated', label: '模型弃用预警', hint: 'AI 网关在池模型 30 天内被 OCI 弃用时随每日探测推送' },
|
||||
{ key: 'logEventInstance', label: '实例生命周期', hint: '创建 / 终止 / 电源操作(Launch、Terminate、InstanceAction)' },
|
||||
{ key: 'logEventIdentity', label: '用户与凭据', hint: '用户增删改、API Key 增删、能力变更' },
|
||||
{ key: 'logEventPolicy', label: '策略变更', hint: 'IAM Policy 创建 / 修改 / 删除' },
|
||||
{ key: 'logEventRegion', label: '区域订阅', hint: '订阅新区域(CreateRegionSubscription)' },
|
||||
{ key: 'logEventLogin', label: '控制台登录', hint: 'OCI 控制台交互登录(InteractiveLogin)' },
|
||||
]
|
||||
|
||||
/** 通知管理按业务域分组展示;新事件类型加入对应组即可 */
|
||||
const eventGroups: { title: string; keys: (keyof NotifyEventsSetting)[] }[] = [
|
||||
{ title: '任务', keys: ['taskFail', 'taskRecover', 'snatchSuccess', 'taskStop', 'modelDeprecated'] },
|
||||
{ title: '租户与安全', keys: ['tenantDead', 'loginLock'] },
|
||||
{
|
||||
title: '云端事件 · 日志回传',
|
||||
keys: ['logEventInstance', 'logEventIdentity', 'logEventPolicy', 'logEventRegion', 'logEventLogin'],
|
||||
},
|
||||
]
|
||||
|
||||
const enabledCount = computed(() => Object.values(eventForm.value).filter(Boolean).length)
|
||||
|
||||
// ---- 通知模板:每个事件可自定义推送 markdown ----
|
||||
const templates = useAsync(listNotifyTemplates)
|
||||
const tplShow = ref(false)
|
||||
const tplCurrent = ref<NotifyTemplateItem | null>(null)
|
||||
const tplHint = ref('')
|
||||
|
||||
/** eventRows 的 camelCase key → 模板 kind(snake_case) */
|
||||
function tplKindOf(key: string) {
|
||||
return key.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase())
|
||||
}
|
||||
|
||||
function openTemplate(row: { key: string; label: string; hint: string }) {
|
||||
const kind = tplKindOf(row.key)
|
||||
const item = (templates.data.value ?? []).find((t) => t.kind === kind)
|
||||
if (!item) {
|
||||
message.error('模板加载中,请稍后再试')
|
||||
return
|
||||
}
|
||||
tplCurrent.value = item
|
||||
tplHint.value = row.hint
|
||||
tplShow.value = true
|
||||
}
|
||||
|
||||
function onTemplateSaved(kind: string, template: string) {
|
||||
const item = (templates.data.value ?? []).find((t) => t.kind === kind)
|
||||
if (item) item.template = template
|
||||
}
|
||||
|
||||
function rowsOf(keys: (keyof NotifyEventsSetting)[]) {
|
||||
return eventRows.filter((r) => keys.includes(r.key))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => events.data.value,
|
||||
(data) => {
|
||||
if (data) eventForm.value = { ...data }
|
||||
},
|
||||
)
|
||||
|
||||
async function saveEvents() {
|
||||
savingEvents.value = true
|
||||
try {
|
||||
await updateNotifyEvents({ ...eventForm.value })
|
||||
message.success('通知管理已保存')
|
||||
void events.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingEvents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 通知方式:Telegram 渠道配置 ----
|
||||
const setting = useAsync(getTelegramSetting)
|
||||
const enabled = ref(false)
|
||||
const chatId = ref('')
|
||||
const botToken = ref('')
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
|
||||
// 服务端数据到达后回填表单;token 不回明文,只以占位符提示已配置态
|
||||
watch(
|
||||
() => setting.data.value,
|
||||
(data) => {
|
||||
if (!data) return
|
||||
enabled.value = data.enabled
|
||||
chatId.value = data.chatId
|
||||
botToken.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
const tokenPlaceholder = computed(() => {
|
||||
const data = setting.data.value
|
||||
if (!data?.tokenSet) return '123456789:AAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
return `已配置(尾号 ${data.tokenTail}),留空沿用`
|
||||
})
|
||||
|
||||
// ---- 任务设置:抢机熔断阈值 ----
|
||||
const taskSetting = useAsync(getTaskSetting)
|
||||
const authFailLimit = ref<number | null>(3)
|
||||
const savingTask = ref(false)
|
||||
|
||||
/** 抢机熔断流程步骤条;hot 标记可配置的关键一步 */
|
||||
const CIRCUIT_STEPS = [
|
||||
{ t: '连续失败', d: '抢机请求连续返回 NotAuthenticated', hot: false },
|
||||
{ t: '达到阈值', d: '失败次数达到下方配置值', hot: true },
|
||||
{ t: '熔断停止', d: '任务置为已熔断并退出调度', hot: false },
|
||||
{ t: '推送与恢复', d: '发送「任务停止」通知,修复 Key 后在任务页重新启用', hot: false },
|
||||
]
|
||||
|
||||
watch(
|
||||
() => taskSetting.data.value,
|
||||
(data) => {
|
||||
if (data) authFailLimit.value = data.snatchAuthFailLimit
|
||||
},
|
||||
)
|
||||
|
||||
// ---- 安全设置:WAF 参数 / 真实IP请求头 / 面板地址 ----
|
||||
const security = useAsync(getSecuritySetting)
|
||||
const securityForm = ref<SecuritySetting>({
|
||||
loginFailLimit: 5,
|
||||
loginLockMinutes: 15,
|
||||
ipRateRps: 10,
|
||||
ipRateBurst: 30,
|
||||
realIpHeader: '',
|
||||
appUrl: '',
|
||||
})
|
||||
const savingSecurity = ref(false)
|
||||
|
||||
/** 真实IP请求头预设;设计稿 segmented 选项,预设外经「自定义」输入 */
|
||||
const REALIP_PRESETS = [
|
||||
{ label: '直连', value: '' },
|
||||
{ label: 'X-Forwarded-For', value: 'X-Forwarded-For' },
|
||||
{ label: 'X-Real-IP', value: 'X-Real-IP' },
|
||||
{ label: 'CF-Connecting-IP', value: 'CF-Connecting-IP' },
|
||||
]
|
||||
const customHeaderMode = ref(false)
|
||||
|
||||
function pickHeader(v: string) {
|
||||
customHeaderMode.value = false
|
||||
securityForm.value.realIpHeader = v
|
||||
}
|
||||
|
||||
function pickCustomHeader() {
|
||||
if (customHeaderMode.value) return
|
||||
customHeaderMode.value = true
|
||||
// 从预设切入自定义时清空,避免误保存预设值
|
||||
if (REALIP_PRESETS.some((p) => p.value === securityForm.value.realIpHeader))
|
||||
securityForm.value.realIpHeader = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => security.data.value,
|
||||
(data) => {
|
||||
if (!data) return
|
||||
securityForm.value = { ...data }
|
||||
customHeaderMode.value =
|
||||
data.realIpHeader !== '' && !REALIP_PRESETS.some((p) => p.value === data.realIpHeader)
|
||||
},
|
||||
)
|
||||
|
||||
async function saveSecurity() {
|
||||
savingSecurity.value = true
|
||||
try {
|
||||
await updateSecuritySetting({ ...securityForm.value })
|
||||
message.success('安全设置已保存,立即生效')
|
||||
void security.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingSecurity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaskSetting() {
|
||||
const limit = authFailLimit.value
|
||||
if (limit == null || limit < 1 || limit > 10) {
|
||||
message.error('熔断阈值须在 1-10 之间')
|
||||
return
|
||||
}
|
||||
savingTask.value = true
|
||||
try {
|
||||
await updateTaskSetting({ snatchAuthFailLimit: limit })
|
||||
message.success('任务设置已保存')
|
||||
void taskSetting.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const initialLoading = computed(
|
||||
() =>
|
||||
(setting.loading.value && !setting.data.value) ||
|
||||
(events.loading.value && !events.data.value) ||
|
||||
(taskSetting.loading.value && !taskSetting.data.value) ||
|
||||
(security.loading.value && !security.data.value),
|
||||
)
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = botToken.value.trim()
|
||||
await updateTelegramSetting({
|
||||
enabled: enabled.value,
|
||||
chatId: chatId.value.trim(),
|
||||
// 留空表示沿用已保存的 token
|
||||
...(token ? { botToken: token } : {}),
|
||||
})
|
||||
message.success('通知设置已保存')
|
||||
botToken.value = ''
|
||||
void setting.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
await testTelegram()
|
||||
message.success('测试消息已发送,请在 Telegram 查收')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发送失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h1 class="page-title">设置</h1>
|
||||
<span class="text-[13px] text-ink-3">面板级系统配置</span>
|
||||
</div>
|
||||
|
||||
<NTabs v-model:value="tab" type="line">
|
||||
<NTabPane name="notify" tab="通知" />
|
||||
<NTabPane name="task" tab="任务" />
|
||||
<NTabPane name="security" tab="安全" />
|
||||
<NTabPane name="about" tab="关于" />
|
||||
</NTabs>
|
||||
|
||||
<div v-if="initialLoading" class="panel flex max-w-[620px] items-center justify-center py-20">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="tab === 'notify'"
|
||||
class="grid max-w-[1180px] grid-cols-[7fr_5fr] items-start gap-4 max-lg:grid-cols-1"
|
||||
>
|
||||
<!-- 左:通知管理(按事件类型控制是否推送) -->
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">通知管理</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
按事件类型控制是否推送;事件仅在状态变化时触发
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-ok" />
|
||||
已开启 {{ enabledCount }} / {{ eventRows.length }} 项
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-4.5 pb-2">
|
||||
<template v-for="group in eventGroups" :key="group.title">
|
||||
<div class="pt-3.5 pb-1 text-[11px] font-semibold tracking-wider text-ink-3 uppercase">
|
||||
{{ group.title }}
|
||||
</div>
|
||||
<div
|
||||
v-for="row in rowsOf(group.keys)"
|
||||
:key="row.key"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">{{ row.label }}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">{{ row.hint }}</div>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer text-xs text-accent hover:opacity-80"
|
||||
@click="openTemplate(row)"
|
||||
>
|
||||
模板
|
||||
</button>
|
||||
<NSwitch v-model:value="eventForm[row.key]" size="small" />
|
||||
</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>
|
||||
|
||||
<!-- 右:通知方式 + 送达规则 -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="panel">
|
||||
<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>
|
||||
<div class="flex items-center gap-3 border-b border-line-soft px-4.5 py-3.5">
|
||||
<span
|
||||
class="flex h-8.5 w-8.5 flex-none items-center justify-center rounded-full bg-info text-[15px] font-bold text-white"
|
||||
>
|
||||
T
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-[13px] font-medium">Telegram</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="enabled ? 'bg-ok' : 'bg-ink-3/50'"
|
||||
/>
|
||||
{{ enabled ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">开启的通知事件推送到指定 chat</div>
|
||||
</div>
|
||||
<NSwitch v-model:value="enabled" size="small" />
|
||||
</div>
|
||||
|
||||
<div class="px-4.5 py-3.5">
|
||||
<FormField
|
||||
label="Bot Token"
|
||||
hint="来自 @BotFather 的 bot 令牌;服务端加密存储,保存后不再回显明文"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="botToken"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="tokenPlaceholder"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Chat ID" hint="接收通知的会话 ID:个人为正整数,群组为负整数">
|
||||
<NInput v-model:value="chatId" placeholder="如 123456789" />
|
||||
</FormField>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
<NButton size="small" :loading="testing" @click="sendTest">发送测试消息</NButton>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-ink-3">测试使用已保存的配置,修改后请先保存</div>
|
||||
</div>
|
||||
|
||||
<FootNote>
|
||||
获取 chat_id:① 用 @BotFather 创建 bot 拿到 token;② 给 bot 发一条消息(群组则把 bot
|
||||
拉进群);③ 浏览器打开 api.telegram.org/bot<token>/getUpdates,响应中
|
||||
message.chat.id 即为 chat_id
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
<div class="panel px-4.5 py-3.5">
|
||||
<div class="text-[13px] font-semibold">送达规则</div>
|
||||
<div class="mt-1.5 text-xs leading-relaxed text-ink-3">
|
||||
发送为异步、10 秒超时,失败仅记后端日志,不影响任务执行;同一事件按状态变化去重,
|
||||
不会刷屏。云端事件另受「日志回传」链路约束——未建链路的租户不产生该类通知。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tab === 'task'" class="flex max-w-[860px] flex-col gap-4">
|
||||
<!-- 任务行为:抢机熔断阈值 -->
|
||||
<div class="panel">
|
||||
<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">
|
||||
API Key 失效后自动停止无意义的重试,避免频繁鉴权失败触发云端风控
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 px-2.5 pt-4.5 pb-1 max-md:grid-cols-2 max-md:gap-y-4">
|
||||
<div
|
||||
v-for="(s, i) in CIRCUIT_STEPS"
|
||||
:key="s.t"
|
||||
class="relative px-2.5 text-center after:absolute after:top-[15px] after:left-[calc(50%+26px)] after:h-[1.5px] after:w-[calc(100%-52px)] after:bg-line last:after:hidden max-md:after:hidden"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-[30px] w-[30px] items-center justify-center rounded-full border-[1.5px] text-[13px] font-semibold"
|
||||
:class="
|
||||
s.hot
|
||||
? 'border-accent bg-accent/15 text-accent'
|
||||
: 'border-line bg-wash text-ink-2'
|
||||
"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</span>
|
||||
<div class="mt-2 text-[12.5px] font-semibold">{{ s.t }}</div>
|
||||
<div class="mt-0.5 text-[11.5px] leading-normal text-ink-3">{{ s.d }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4.5 pt-3 pb-4.5">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<AppInputNumber
|
||||
v-model:value="authFailLimit"
|
||||
:min="1"
|
||||
:max="10"
|
||||
size="large"
|
||||
class="!w-28"
|
||||
/>
|
||||
<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;保存后下一次抢机执行即按新阈值判定
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-3 max-md:grid-cols-1">
|
||||
<div class="rounded-lg bg-wash px-3.5 py-3">
|
||||
<div class="text-[11px] tracking-wider text-ink-3 uppercase">当前生效值</div>
|
||||
<div class="mt-1 text-[13px] font-semibold">
|
||||
{{ taskSetting.data.value ? `${taskSetting.data.value.snatchAuthFailLimit} 次` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-wash px-3.5 py-3">
|
||||
<div class="text-[11px] tracking-wider text-ink-3 uppercase">受控任务</div>
|
||||
<div class="mt-1 text-[13px] font-semibold">抢机任务(snatch)</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-wash px-3.5 py-3">
|
||||
<div class="text-[11px] tracking-wider text-ink-3 uppercase">熔断后恢复方式</div>
|
||||
<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 失效);容量不足等业务失败不计入,抢机会持续重试
|
||||
</FootNote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全:双栏 —— 左访问防护 / 网络与地址,右账号安全与登录方式 -->
|
||||
<div v-else-if="tab === 'security'" class="grid max-w-[1180px] grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="panel">
|
||||
<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">
|
||||
登录守卫与全局 IP 限速,超限返回 429;锁定事件可推送告警(通知管理 → 登录锁定)
|
||||
</div>
|
||||
</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" />
|
||||
</FormField>
|
||||
<FormField label="锁定时长(分钟)" hint="锁定持续时间,同时是失败计数窗口(1-1440)">
|
||||
<AppInputNumber v-model:value="securityForm.loginLockMinutes" :min="1" :max="1440" />
|
||||
</FormField>
|
||||
<FormField label="限速(req/s)" hint="每 IP 令牌桶每秒补充令牌数(1-100)">
|
||||
<AppInputNumber v-model:value="securityForm.ipRateRps" :min="1" :max="100" />
|
||||
</FormField>
|
||||
<FormField label="突发额度" hint="令牌桶容量,允许的瞬时突发请求数(1-500)">
|
||||
<AppInputNumber v-model:value="securityForm.ipRateBurst" :min="1" :max="500" />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<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">
|
||||
反代部署时必须正确选择真实IP请求头,否则限速与锁定会作用在反代 IP 上
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4.5 py-3.5">
|
||||
<FormField
|
||||
label="真实IP请求头"
|
||||
hint="Caddy/Nginx 反代选 X-Forwarded-For;Cloudflare 前置时选 CF-Connecting-IP;直连部署保持「直连」防伪造"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in REALIP_PRESETS"
|
||||
:key="opt.label"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border px-3 py-1.5 text-xs"
|
||||
:class="
|
||||
!customHeaderMode && securityForm.realIpHeader === opt.value
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2 hover:border-ink-3'
|
||||
"
|
||||
@click="pickHeader(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border border-dashed px-3 py-1.5 text-xs"
|
||||
:class="
|
||||
customHeaderMode
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2 hover:border-ink-3'
|
||||
"
|
||||
@click="pickCustomHeader"
|
||||
>
|
||||
自定义…
|
||||
</button>
|
||||
</div>
|
||||
<NInput
|
||||
v-if="customHeaderMode"
|
||||
v-model:value="securityForm.realIpHeader"
|
||||
class="mono mt-2 !w-70"
|
||||
placeholder="自定义头名,如 X-Client-IP"
|
||||
/>
|
||||
<div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3">
|
||||
头名仅限字母数字与连字符
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="面板地址"
|
||||
hint="面板公网基址,优先于 PUBLIC_URL 环境变量;日志回传一键创建与 OAuth 回调以此拼接 URL"
|
||||
>
|
||||
<NInput v-model:value="securityForm.appUrl" placeholder="https://demo.example.com" />
|
||||
</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
|
||||
取链尾(反代追加的直连对端),链首可被客户端伪造不采信
|
||||
</FootNote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号安全:两步验证 / 外部身份绑定 / 登录方式配置 -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<AccountSecurityCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于:构建信息与项目主页(v-else-if 挂载时才启动粒子动画) -->
|
||||
<AboutTab v-else-if="tab === 'about'" />
|
||||
|
||||
<NotifyTemplateModal
|
||||
v-model:show="tplShow"
|
||||
:item="tplCurrent"
|
||||
:hint="tplHint"
|
||||
@saved="onTemplateSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, useDialog, useMessage } from 'naive-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks'
|
||||
import EmptyCard from '@/components/EmptyCard.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import TaskFormModal from '@/components/task/TaskFormModal.vue'
|
||||
import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue'
|
||||
import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/task/taskDisplay'
|
||||
import { fmtRelative, fmtTime, regionCity } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
|
||||
const taskId = computed(() => Number(route.params.taskId))
|
||||
|
||||
const task = useAsync(() => getTask(taskId.value))
|
||||
const logs = useAsync(() => listTaskLogs(taskId.value))
|
||||
|
||||
// 每 5 秒静默刷新任务与事件流
|
||||
const refreshTimer = setInterval(() => {
|
||||
void task.run({ silent: true })
|
||||
void logs.run({ silent: true })
|
||||
}, 5000)
|
||||
onUnmounted(() => clearInterval(refreshTimer))
|
||||
|
||||
const snatch = computed(() => (task.data.value ? parseSnatch(task.data.value) : null))
|
||||
const status = computed(() => (task.data.value ? STATUS_META[task.data.value.status] : null))
|
||||
const cfgAlias = computed(() => {
|
||||
const s = snatch.value
|
||||
if (!s) return ''
|
||||
return (scope.configs.data ?? []).find((c) => c.id === s.cfgId)?.alias ?? `租户 #${s.cfgId}`
|
||||
})
|
||||
|
||||
const pct = computed(() => {
|
||||
const s = snatch.value
|
||||
if (!s?.total) return 0
|
||||
return Math.min(100, Math.round((s.done / s.total) * 100))
|
||||
})
|
||||
|
||||
/** 右栏任务参数键值对(按类型组装) */
|
||||
const params = computed<[string, string][]>(() => {
|
||||
const t = task.data.value
|
||||
if (!t) return []
|
||||
const s = snatch.value
|
||||
if (s) {
|
||||
return [
|
||||
['所属租户', cfgAlias.value],
|
||||
['目标 Shape', s.shape],
|
||||
['规格', s.ocpus ? `${s.ocpus} OCPU · ${s.memoryInGBs} GB` : '—'],
|
||||
['引导卷', s.bootGb ? `${s.bootGb} GB` : '默认'],
|
||||
['实例名', s.displayName ?? '—'],
|
||||
['目标数量', `${s.total} 台`],
|
||||
['剩余数量', `${s.remaining} 台`],
|
||||
['调度周期', t.cronExpr],
|
||||
['区域', s.region ? regionCity(s.region) : '默认'],
|
||||
]
|
||||
}
|
||||
const ids = scopeCfgIds(t)
|
||||
const scope = t.type === 'ai_probe'
|
||||
? '号池渠道对应租户'
|
||||
: ids.length ? `${ids.length} 个租户` : '全部租户'
|
||||
return [
|
||||
['类型', TYPE_LABEL[t.type]],
|
||||
['范围', scope],
|
||||
['调度周期', t.cronExpr],
|
||||
['已运行', `${t.runCount} 次`],
|
||||
]
|
||||
})
|
||||
|
||||
const showEdit = ref(false)
|
||||
|
||||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
try {
|
||||
await fn()
|
||||
message.success(ok)
|
||||
void task.run({ silent: true })
|
||||
void logs.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** active ↔ paused 切换;failed(熔断停止)同走恢复交互重新启用 */
|
||||
function toggle() {
|
||||
const t = task.data.value
|
||||
if (!t) return
|
||||
const resume = t.status !== 'active'
|
||||
void act(
|
||||
() => updateTask(t.id, { status: resume ? 'active' : 'paused' }),
|
||||
resume ? (t.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
|
||||
)
|
||||
}
|
||||
|
||||
function confirmRemove() {
|
||||
const t = task.data.value
|
||||
if (!t) return
|
||||
dialog.warning({
|
||||
title: '删除任务',
|
||||
content: `删除任务「${t.name}」及其全部日志?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await deleteTask(t.id)
|
||||
message.success('已删除任务及其日志')
|
||||
void router.push({ name: 'tasks' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const features = computed(() => {
|
||||
const base = [
|
||||
['持久化存储', '任务参数与进度写入数据库,不依赖内存状态'],
|
||||
['重启自动恢复', '服务重启后自动加载未完成任务并继续调度'],
|
||||
]
|
||||
if (task.data.value?.type === 'snatch')
|
||||
base.push(['抢满自动停止', '达到目标数量后任务自动标记完成,不再调度'])
|
||||
else base.push(['日志滚动保留', '每个任务保留最近 100 条执行日志'])
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<RouterLink
|
||||
:to="{ name: 'tasks' }"
|
||||
class="flex w-fit items-center gap-1.5 text-[13px] text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
返回任务列表
|
||||
</RouterLink>
|
||||
|
||||
<div
|
||||
v-if="task.loading.value && !task.data.value"
|
||||
class="panel flex items-center justify-center py-20"
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
v-else-if="task.data.value"
|
||||
class="grid grid-cols-[minmax(0,1fr)_360px] items-start gap-4 max-lg:grid-cols-1"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<!-- 头部:名称 + 操作 + 进度 -->
|
||||
<div class="panel px-6 py-5">
|
||||
<div class="flex flex-wrap items-start gap-3.5">
|
||||
<div class="flex h-11 w-11 flex-none items-center justify-center rounded-[10px] bg-wash">
|
||||
<TaskTypeIcon :type="task.data.value.type" class="h-[22px] w-[22px] text-accent" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<span class="page-title !text-xl">{{ task.data.value.name }}</span>
|
||||
<StatusBadge v-if="status" :kind="status.kind" :label="status.label" />
|
||||
</div>
|
||||
<div class="mono mt-1 text-xs text-ink-3">
|
||||
{{ TYPE_LABEL[task.data.value.type] }} · {{ task.data.value.cronExpr }} · 创建于
|
||||
{{ fmtTime(task.data.value.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="act(() => runTask(task.data.value!.id), '已触发执行')"
|
||||
>
|
||||
立即执行
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="task.data.value.status !== 'succeeded'"
|
||||
size="small"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ task.data.value.status === 'active' ? '暂停' : task.data.value.status === 'failed' ? '启用' : '恢复' }}
|
||||
</NButton>
|
||||
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" @click="showEdit = true">编辑</NButton>
|
||||
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" type="error" quaternary @click="confirmRemove">删除</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="snatch" class="mt-5">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-[13px] text-ink-3">创建进度</span>
|
||||
<span class="mono text-[15px]">
|
||||
<b
|
||||
class="text-lg"
|
||||
:style="{
|
||||
color:
|
||||
task.data.value.status === 'succeeded'
|
||||
? 'var(--color-ok)'
|
||||
: 'var(--color-accent)',
|
||||
}"
|
||||
>{{ snatch.done }}</b>
|
||||
/ {{ snatch.total }} 台
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-line-soft">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:style="{
|
||||
width: pct + '%',
|
||||
background:
|
||||
task.data.value.status === 'succeeded'
|
||||
? 'var(--color-ok)'
|
||||
: 'var(--color-accent)',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-ink-3">
|
||||
已尝试 {{ task.data.value.runCount }} 次 · 上次运行
|
||||
{{ fmtRelative(task.data.value.lastRunAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="task.data.value.lastError"
|
||||
class="mt-4 rounded-md border border-err/30 bg-err/10 px-3 py-2 text-[13px] break-all text-err"
|
||||
>
|
||||
{{ task.data.value.lastError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件流 -->
|
||||
<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">
|
||||
<div
|
||||
v-for="(log, i) in logs.data.value ?? []"
|
||||
:key="log.id"
|
||||
class="relative flex gap-3 pb-5 last:pb-1"
|
||||
>
|
||||
<div
|
||||
v-if="i < (logs.data.value?.length ?? 0) - 1"
|
||||
class="absolute top-4 bottom-0 left-[5px] w-px bg-line-soft"
|
||||
/>
|
||||
<div
|
||||
class="mt-1 h-[11px] w-[11px] flex-none rounded-full border-[2.5px]"
|
||||
:style="{
|
||||
borderColor: log.success ? 'var(--color-ok)' : 'var(--color-err)',
|
||||
background: `color-mix(in srgb, ${log.success ? 'var(--color-ok)' : 'var(--color-err)'} 22%, var(--color-card))`,
|
||||
}"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mono text-[11.5px] text-ink-3">{{ fmtTime(log.createdAt) }}</span>
|
||||
<span
|
||||
class="rounded px-1.5 text-[10.5px] font-semibold tracking-wide"
|
||||
:style="{
|
||||
color: log.success ? 'var(--color-ok)' : 'var(--color-err)',
|
||||
background: `color-mix(in srgb, ${log.success ? 'var(--color-ok)' : 'var(--color-err)'} 10%, transparent)`,
|
||||
}"
|
||||
>
|
||||
{{ log.success ? 'SUCCESS' : 'FAILED' }}
|
||||
</span>
|
||||
<span class="mono text-[11.5px] text-ink-3">{{ log.durationMs }}ms</span>
|
||||
</div>
|
||||
<div class="mono mt-0.5 text-[13px] break-all">{{ log.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyCard
|
||||
v-if="logs.data.value && !logs.data.value.length"
|
||||
title="暂无事件"
|
||||
note="任务执行后事件流会出现在这里"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏 -->
|
||||
<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">
|
||||
<div
|
||||
v-for="[k, v] in params"
|
||||
:key="k"
|
||||
class="flex items-baseline justify-between gap-3 border-b border-line-soft py-2.5 last:border-b-0"
|
||||
>
|
||||
<span class="flex-none text-[13px] text-ink-3">{{ k }}</span>
|
||||
<span class="mono min-w-0 truncate text-[13px]" :title="v">{{ v }}</span>
|
||||
</div>
|
||||
</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">
|
||||
<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"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 12.5l5 5L20 6.5" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-[13px] font-semibold">{{ t }}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">{{ d }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskFormModal v-model:show="showEdit" :task="task.data.value" @created="task.run()" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, useDialog, useMessage } from 'naive-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { deleteTask, listTasks, runTask, updateTask } from '@/api/tasks'
|
||||
import EmptyCard from '@/components/EmptyCard.vue'
|
||||
import TaskCard from '@/components/task/TaskCard.vue'
|
||||
import TaskFormModal from '@/components/task/TaskFormModal.vue'
|
||||
import { parseSnatch } from '@/components/task/taskDisplay'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { Task } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
|
||||
const tasks = useAsync(listTasks)
|
||||
const showCreate = ref(false)
|
||||
const editingTask = ref<Task | null>(null)
|
||||
|
||||
// 每 5 秒静默刷新(不闪 loading)
|
||||
const refreshTimer = setInterval(() => void tasks.run({ silent: true }), 5000)
|
||||
onUnmounted(() => clearInterval(refreshTimer))
|
||||
|
||||
const cfgAliasById = computed(
|
||||
() => new Map((scope.configs.data ?? []).map((c) => [c.id, c.alias])),
|
||||
)
|
||||
|
||||
function aliasFor(task: Task): string | undefined {
|
||||
const s = parseSnatch(task)
|
||||
return s ? cfgAliasById.value.get(s.cfgId) : undefined
|
||||
}
|
||||
|
||||
function openEdit(task: Task) {
|
||||
editingTask.value = task
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingTask.value = null
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
try {
|
||||
await fn()
|
||||
message.success(ok)
|
||||
void tasks.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** active ↔ paused 切换;failed(熔断停止)同走恢复交互重新启用 */
|
||||
function toggle(task: Task) {
|
||||
const resume = task.status !== 'active'
|
||||
void act(
|
||||
() => updateTask(task.id, { status: resume ? 'active' : 'paused' }),
|
||||
resume ? (task.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
|
||||
)
|
||||
}
|
||||
|
||||
function confirmRemove(task: Task) {
|
||||
dialog.warning({
|
||||
title: '删除任务',
|
||||
content: `删除任务「${task.name}」及其全部日志?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => act(() => deleteTask(task.id), '已删除任务及其日志'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h1 class="page-title">任务</h1>
|
||||
<span class="text-[13px] text-ink-3">
|
||||
{{ tasks.data.value?.length ?? '…' }} 个任务 · cron 为服务器本地时区
|
||||
</span>
|
||||
</div>
|
||||
<NButton size="small" type="primary" @click="openCreate">新建任务</NButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="tasks.loading.value && !tasks.data.value"
|
||||
class="panel flex items-center justify-center py-20"
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasks.data.value ?? []"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:cfg-alias="aliasFor(task)"
|
||||
@run="act(() => runTask(task.id), `已触发执行:${task.name}`)"
|
||||
@toggle="toggle(task)"
|
||||
@edit="openEdit(task)"
|
||||
@remove="confirmRemove(task)"
|
||||
/>
|
||||
<EmptyCard
|
||||
v-if="tasks.data.value && !tasks.data.value.length"
|
||||
title="暂无任务"
|
||||
note="点击右上角「新建任务」创建测活 / 抢机 / 成本任务"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TaskFormModal v-model:show="showCreate" :task="editingTask" @created="tasks.run()" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NPopconfirm, NSpin, NTabPane, NTabs, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { deleteConfig, getConfig, verifyConfig } from '@/api/configs'
|
||||
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
|
||||
import AuditLogTab from '@/components/tenant/AuditLogTab.vue'
|
||||
import CostTab from '@/components/tenant/CostTab.vue'
|
||||
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
||||
import IdpTab from '@/components/tenant/IdpTab.vue'
|
||||
import MiscTab from '@/components/tenant/MiscTab.vue'
|
||||
import OcidText from '@/components/OcidText.vue'
|
||||
import QuotaTab from '@/components/tenant/QuotaTab.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import SubsTab from '@/components/tenant/SubsTab.vue'
|
||||
import UsersTab from '@/components/tenant/UsersTab.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
|
||||
const scope = useScopeStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
const cfgId = computed(() => Number(route.params.id))
|
||||
const tab = ref('quota')
|
||||
const verifying = ref(false)
|
||||
|
||||
const config = useAsync(() => getConfig(cfgId.value))
|
||||
|
||||
/** 失联租户各 tab 拉不到云端数据,内容区整体换失联占位 */
|
||||
const isDead = computed(() => config.data.value?.aliveStatus === 'dead')
|
||||
|
||||
async function verify() {
|
||||
verifying.value = true
|
||||
try {
|
||||
const result = await verifyConfig(cfgId.value)
|
||||
const changed = Object.keys(result.changes).length
|
||||
message.success(changed ? `测活完成,${changed} 个字段有变更` : '测活完成,无变更')
|
||||
void config.run()
|
||||
void scope.refreshConfigs()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '测活失败')
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
try {
|
||||
await deleteConfig(cfgId.value)
|
||||
message.success('已删除(仅移除本面板配置,不影响云端资源)')
|
||||
void scope.refreshConfigs()
|
||||
void router.push({ name: 'tenants' })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div
|
||||
v-if="config.loading.value && !config.data.value"
|
||||
class="panel flex items-center justify-center py-20"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div v-if="config.data.value" class="panel px-5 pt-4 pb-0">
|
||||
<RouterLink
|
||||
class="mb-2 inline-flex items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
|
||||
:to="{ name: 'tenants' }"
|
||||
>
|
||||
← 返回租户列表
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="page-title">{{ config.data.value.alias }}</h1>
|
||||
<NTooltip
|
||||
v-if="isDead && config.data.value.lastError"
|
||||
trigger="hover"
|
||||
placement="top-start"
|
||||
>
|
||||
<template #trigger>
|
||||
<StatusBadge kind="term" label="失联" />
|
||||
</template>
|
||||
<div class="max-w-80 text-xs">{{ config.data.value.lastError }}</div>
|
||||
</NTooltip>
|
||||
<StatusBadge
|
||||
v-else
|
||||
:kind="config.data.value.aliveStatus === 'alive' ? 'run' : 'term'"
|
||||
:label="config.data.value.aliveStatus === 'alive' ? '存活' : '失联'"
|
||||
/>
|
||||
<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">
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost>删除租户</NButton>
|
||||
</template>
|
||||
删除「{{ config.data.value.alias }}」?仅移除本面板配置与快照,不影响云端资源。
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-4 gap-4 pb-4 max-lg:grid-cols-2 max-md:grid-cols-1">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">租户名</div>
|
||||
<div class="mono mt-0.5 truncate text-[13px]">{{ config.data.value.tenancyName }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">Tenancy OCID</div>
|
||||
<div class="mt-0.5"><OcidText :ocid="config.data.value.tenancyOcid" /></div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">主区域</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ regionAlias(config.data.value.region) }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">导入时间</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ fmtTime(config.data.value.createdAt).slice(0, 10) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NTabs v-model:value="tab" type="line" class="tenant-tabs">
|
||||
<NTabPane name="quota" tab="配额" />
|
||||
<NTabPane name="idp" tab="IDP" />
|
||||
<NTabPane name="users" tab="用户" />
|
||||
<NTabPane name="cost" tab="成本" />
|
||||
<NTabPane name="subs" tab="订阅" />
|
||||
<NTabPane name="audit" tab="审计日志" />
|
||||
<NTabPane name="misc" tab="其他" />
|
||||
</NTabs>
|
||||
</div>
|
||||
|
||||
<template v-if="config.data.value">
|
||||
<DeadPlaceholder v-if="isDead" :last-error="config.data.value.lastError" />
|
||||
<template v-else>
|
||||
<QuotaTab v-if="tab === 'quota'" :cfg-id="cfgId" />
|
||||
<IdpTab v-else-if="tab === 'idp'" :cfg-id="cfgId" />
|
||||
<UsersTab v-else-if="tab === 'users'" :cfg-id="cfgId" />
|
||||
<CostTab v-else-if="tab === 'cost'" :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 :cfg-id="cfgId" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tenant-tabs :deep(.n-tabs-nav) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NPopconfirm, NPopover, NSelect, NTooltip, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, ref, 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 AccountTypeBadge from '@/components/AccountTypeBadge.vue'
|
||||
import EditConfigModal from '@/components/config/EditConfigModal.vue'
|
||||
import ImportConfigModal from '@/components/config/ImportConfigModal.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/task/taskDisplay'
|
||||
import { fmtRelative, fmtTime } from '@/composables/useFormat'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { AccountType, OciConfigSummary, Task } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
const scope = useScopeStore()
|
||||
|
||||
/** 租户增删改 / 测活后本页列表与全局作用域选择器一起刷新 */
|
||||
function reloadConfigs() {
|
||||
void configs.run()
|
||||
void scope.refreshConfigs()
|
||||
}
|
||||
const keyword = ref('')
|
||||
const UNGROUPED = '__ungrouped__'
|
||||
const groupFilter = ref<string | null>(null)
|
||||
const verifying = ref<number | null>(null)
|
||||
const showImport = ref(false)
|
||||
const showEdit = ref(false)
|
||||
const editing = ref<OciConfigSummary | null>(null)
|
||||
|
||||
const configs = useAsync(listConfigs)
|
||||
const tasks = useAsync(listTasks)
|
||||
/** AI 探测任务的作用面 = 号池渠道对应租户 */
|
||||
const aiChannels = useAsync(listAiChannels)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
const list = configs.data.value ?? []
|
||||
const names = [...new Set(list.map((c) => c.group).filter(Boolean))].sort()
|
||||
const options = names.map((g) => ({ label: g, value: g }))
|
||||
if (list.some((c) => !c.group)) options.push({ label: '未分组', value: UNGROUPED })
|
||||
return options
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
let list = configs.data.value ?? []
|
||||
const g = groupFilter.value
|
||||
if (g !== null) list = list.filter((c) => (g === UNGROUPED ? !c.group : c.group === g))
|
||||
if (!kw) return list
|
||||
return list.filter((c) =>
|
||||
[c.alias, c.tenancyName, c.region, c.tenancyOcid, c.group].some((v) =>
|
||||
v.toLowerCase().includes(kw),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
/** 任务是否作用于该租户:抢机看目标租户;AI 探测看号池渠道(仅入池租户);
|
||||
* 测活 / 成本看范围(空 = 全部租户) */
|
||||
function taskTouchesCfg(task: Task, cfgId: number): boolean {
|
||||
const s = parseSnatch(task)
|
||||
if (s) return s.cfgId === cfgId
|
||||
if (task.type === 'ai_probe')
|
||||
return (aiChannels.data.value ?? []).some((ch) => ch.ociConfigId === cfgId)
|
||||
const ids = scopeCfgIds(task)
|
||||
return !ids.length || ids.includes(cfgId)
|
||||
}
|
||||
|
||||
function tasksFor(cfgId: number): Task[] {
|
||||
return (tasks.data.value ?? []).filter((t) => taskTouchesCfg(t, cfgId))
|
||||
}
|
||||
|
||||
async function verify(row: OciConfigSummary) {
|
||||
verifying.value = row.id
|
||||
try {
|
||||
const result = await verifyConfig(row.id)
|
||||
const changed = Object.keys(result.changes).length
|
||||
message.success(changed ? `测活完成,${changed} 个字段有变更` : '测活完成,无变更')
|
||||
reloadConfigs()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '测活失败')
|
||||
} finally {
|
||||
verifying.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: OciConfigSummary) {
|
||||
editing.value = row
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
/** 把该租户与其主区域写入全局作用域选择器(顶栏立即生效) */
|
||||
function setGlobal(row: OciConfigSummary) {
|
||||
scope.setGlobalScope(row.id, row.region)
|
||||
message.success(`全局已切换到「${row.alias}」· ${regionAlias(row.region)}`)
|
||||
}
|
||||
|
||||
async function remove(row: OciConfigSummary) {
|
||||
try {
|
||||
await deleteConfig(row.id)
|
||||
message.success(`已删除「${row.alias}」(仅移除本面板配置,不影响云端资源)`)
|
||||
reloadConfigs()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function renderTenant(row: OciConfigSummary): VNodeChild {
|
||||
return h('div', { class: 'min-w-0' }, [
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
class: 'font-medium text-ink hover:text-accent',
|
||||
to: { name: 'tenant-detail', params: { id: row.id } },
|
||||
},
|
||||
{ default: () => row.alias },
|
||||
),
|
||||
h('div', { class: 'mono text-xs text-ink-3 mt-px' }, row.tenancyName),
|
||||
])
|
||||
}
|
||||
|
||||
/** 状态列:仅徽标;失联且有错误详情时 hover 徽标以 tooltip 展示完整 lastError */
|
||||
function renderStatus(row: OciConfigSummary): VNodeChild {
|
||||
const badge = h(StatusBadge, {
|
||||
kind: row.aliveStatus === 'alive' ? 'run' : row.aliveStatus === 'dead' ? 'term' : 'check',
|
||||
label: row.aliveStatus === 'alive' ? '存活' : row.aliveStatus === 'dead' ? '失联' : '未测',
|
||||
})
|
||||
if (row.aliveStatus !== 'dead' || !row.lastError) return badge
|
||||
return h(
|
||||
NTooltip,
|
||||
{ trigger: 'hover', placement: 'top-start' },
|
||||
{
|
||||
trigger: () => badge,
|
||||
default: () => h('div', { class: 'max-w-80 text-xs' }, row.lastError),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 「后台任务」列:数量 + 悬停任务清单(点击任务名进详情) */
|
||||
function renderTasks(row: OciConfigSummary): VNodeChild {
|
||||
const list = tasksFor(row.id)
|
||||
if (!list.length) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
|
||||
return h(
|
||||
NPopover,
|
||||
{ trigger: 'hover', placement: 'bottom-start' },
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class:
|
||||
'mono cursor-pointer border-b border-dashed border-ink-3/60 text-[13px] font-medium hover:text-accent',
|
||||
onClick: () => router.push({ name: 'tasks' }),
|
||||
},
|
||||
String(list.length),
|
||||
),
|
||||
default: () => renderTaskPopover(list),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function renderTaskPopover(list: Task[]): VNodeChild {
|
||||
return h('div', { class: 'flex min-w-52 flex-col gap-1.5' }, [
|
||||
h('div', { class: 'text-xs text-ink-3' }, '关联后台任务 · 点击查看详情'),
|
||||
...list.map((t) =>
|
||||
h('div', { class: 'flex items-center justify-between gap-4' }, [
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'cursor-pointer text-left text-[13px] text-ink hover:text-accent',
|
||||
onClick: () => router.push({ name: 'task-detail', params: { taskId: t.id } }),
|
||||
},
|
||||
`${TYPE_LABEL[t.type]} · ${t.name}`,
|
||||
),
|
||||
h(StatusBadge, { kind: STATUS_META[t.status].kind, label: STATUS_META[t.status].label }),
|
||||
]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
/** 代理列:标签示关联态,hover 显示代理名 */
|
||||
function renderProxy(row: OciConfigSummary): VNodeChild {
|
||||
if (!row.proxyId) return h('span', { class: 'text-[13px] text-ink-3' }, '直连')
|
||||
return h(
|
||||
NTooltip,
|
||||
{ trigger: 'hover' },
|
||||
{
|
||||
trigger: () => h(StatusBadge, { kind: 'run', label: '代理', dot: false }),
|
||||
default: () => row.proxyName || `#${row.proxyId}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function renderActions(row: OciConfigSummary): VNodeChild {
|
||||
return h('div', { class: 'flex items-center gap-1' }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
quaternary: true,
|
||||
disabled: scope.cfgId === row.id && scope.region === row.region,
|
||||
onClick: () => setGlobal(row),
|
||||
},
|
||||
{ default: () => '设为全局' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, loading: verifying.value === row.id, onClick: () => verify(row) },
|
||||
{ default: () => '测活' },
|
||||
),
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => edit(row) }, { default: () => '修改' }),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => remove(row), negativeText: '取消', positiveText: '删除' },
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
|
||||
default: () => `删除「${row.alias}」?仅移除本面板配置与快照,不影响云端资源。`,
|
||||
},
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
const typeOrder: Record<AccountType, number> = { paid: 0, trial: 1, free: 2, unknown: 3 }
|
||||
|
||||
const columns = computed<DataTableColumns<OciConfigSummary>>(() => [
|
||||
{
|
||||
title: '租户',
|
||||
key: 'alias',
|
||||
minWidth: 200,
|
||||
sorter: (a, b) => a.alias.localeCompare(b.alias, 'zh-CN'),
|
||||
render: renderTenant,
|
||||
},
|
||||
{
|
||||
title: '分组',
|
||||
key: 'group',
|
||||
width: 100,
|
||||
sorter: (a, b) => a.group.localeCompare(b.group, 'zh-CN'),
|
||||
render: (row) =>
|
||||
row.group
|
||||
? h('span', { class: 'text-[13px]' }, row.group)
|
||||
: h('span', { class: 'text-[13px] text-ink-3' }, '—'),
|
||||
},
|
||||
{
|
||||
title: '主区域',
|
||||
key: 'region',
|
||||
width: 195,
|
||||
sorter: (a, b) => regionAlias(a.region).localeCompare(regionAlias(b.region), 'zh-CN'),
|
||||
render: (row) =>
|
||||
h('span', { class: 'text-[13px] whitespace-nowrap' }, regionAlias(row.region)),
|
||||
},
|
||||
{
|
||||
title: '类别',
|
||||
key: 'accountType',
|
||||
width: 90,
|
||||
sorter: (a, b) => typeOrder[a.accountType] - typeOrder[b.accountType],
|
||||
render: (row) => h(AccountTypeBadge, { type: row.accountType }),
|
||||
},
|
||||
{ title: '状态', key: 'aliveStatus', width: 120, render: renderStatus },
|
||||
{ title: '代理', key: 'proxyId', width: 80, render: renderProxy },
|
||||
{ title: '后台任务', key: 'tasks', width: 90, render: renderTasks },
|
||||
{
|
||||
title: '导入时间',
|
||||
key: 'createdAt',
|
||||
width: 110,
|
||||
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
|
||||
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(row.createdAt).slice(0, 10)),
|
||||
},
|
||||
{
|
||||
title: '上次测活',
|
||||
key: 'lastVerifiedAt',
|
||||
width: 110,
|
||||
sorter: (a, b) => (a.lastVerifiedAt ?? '').localeCompare(b.lastVerifiedAt ?? ''),
|
||||
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, fmtRelative(row.lastVerifiedAt)),
|
||||
},
|
||||
{ title: '操作', key: 'actions', width: 226, render: renderActions },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h1 class="page-title">租户</h1>
|
||||
<span class="text-[13px] text-ink-3">{{ configs.data.value?.length ?? '…' }} 个租户</span>
|
||||
</div>
|
||||
<NButton size="small" type="primary" @click="showImport = true">导入租户</NButton>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="flex flex-wrap items-center gap-2.5 px-4.5 py-3.5">
|
||||
<NInputGroup class="!w-56 max-md:!w-full">
|
||||
<NInputGroupLabel size="small">分组</NInputGroupLabel>
|
||||
<NSelect
|
||||
v-model:value="groupFilter"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
:options="groupOptions"
|
||||
:disabled="!groupOptions.length"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<div class="max-w-72 min-w-40 flex-1 max-md:max-w-full">
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
placeholder="搜索别名 / 租户名 / OCID / 分组"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="filtered"
|
||||
:loading="configs.loading.value"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
:scroll-x="1200"
|
||||
:row-key="(r: OciConfigSummary) => r.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImportConfigModal v-model:show="showImport" @imported="reloadConfigs()" />
|
||||
<EditConfigModal v-model:show="showEdit" :config="editing" @updated="reloadConfigs()" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NPopconfirm,
|
||||
NSpin,
|
||||
useMessage,
|
||||
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 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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const cfgId = computed(() => Number(route.params.cfgId))
|
||||
const vcnId = computed(() => String(route.params.vcnId))
|
||||
/** 列表页跳转时带上查询区域,非主区域 VCN 的所有请求都要发往该区域 */
|
||||
const region = computed(() => (route.query.region as string) || undefined)
|
||||
|
||||
const vcn = useAsync(() => getVcn(cfgId.value, vcnId.value, region.value))
|
||||
const subnets = useAsync(() => listSubnets(cfgId.value, vcnId.value, region.value))
|
||||
const seclists = useAsync(() => listSecurityLists(cfgId.value, vcnId.value, region.value))
|
||||
|
||||
const ipv6Steps = ref<Ipv6Step[] | null>(null)
|
||||
const enabling = ref(false)
|
||||
|
||||
async function runEnableIpv6() {
|
||||
enabling.value = true
|
||||
try {
|
||||
const report = await enableVcnIpv6(cfgId.value, vcnId.value, region.value)
|
||||
ipv6Steps.value = report.steps
|
||||
const failed = report.steps.filter((s) => s.status === 'failed').length
|
||||
if (failed) message.warning(`${failed} 个步骤失败,详见执行报告`)
|
||||
else message.success('一键 IPv6 执行完成')
|
||||
void vcn.run()
|
||||
void subnets.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '执行失败')
|
||||
} finally {
|
||||
enabling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const showRename = ref(false)
|
||||
const renaming = ref(false)
|
||||
|
||||
async function doRename(name: string) {
|
||||
renaming.value = true
|
||||
try {
|
||||
await renameVcn(cfgId.value, vcnId.value, name, region.value)
|
||||
message.success('已重命名')
|
||||
showRename.value = false
|
||||
void vcn.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重命名失败')
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const subnetColumns: DataTableColumns<Subnet> = [
|
||||
{
|
||||
title: '子网',
|
||||
key: 'displayName',
|
||||
minWidth: 170,
|
||||
render: (row) =>
|
||||
h('div', [
|
||||
h('div', { class: 'font-medium' }, row.displayName),
|
||||
h('div', { class: 'mono text-xs text-ink-3 mt-px' }, row.dnsLabel),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: 'IPv4 CIDR',
|
||||
key: 'cidrBlock',
|
||||
minWidth: 120,
|
||||
render: (r) => h('span', { class: 'mono' }, r.cidrBlock),
|
||||
},
|
||||
{
|
||||
title: 'IPv6 /64',
|
||||
key: 'ipv6CidrBlock',
|
||||
minWidth: 200,
|
||||
render: (r) =>
|
||||
r.ipv6CidrBlock
|
||||
? h('span', { class: 'mono text-[12.5px]' }, r.ipv6CidrBlock)
|
||||
: h('span', { class: 'text-xs text-ink-3' }, '未启用'),
|
||||
},
|
||||
{
|
||||
title: '公网 IP',
|
||||
key: 'prohibitPublicIp',
|
||||
width: 100,
|
||||
render: (r) =>
|
||||
h(StatusBadge, {
|
||||
kind: r.prohibitPublicIp ? 'stop' : 'run',
|
||||
label: r.prohibitPublicIp ? '禁止' : '允许',
|
||||
dot: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'lifecycleState',
|
||||
width: 120,
|
||||
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
|
||||
},
|
||||
]
|
||||
|
||||
function ruleSummary(list: SecurityList): string {
|
||||
const ingress = list.ingressRules ?? []
|
||||
const egress = list.egressRules ?? []
|
||||
const ing = ingress.length
|
||||
const eg = egress.length
|
||||
const allOpen =
|
||||
ingress.some((r) => r.protocol === 'all' && r.source === '0.0.0.0/0') &&
|
||||
egress.some((r) => r.protocol === 'all' && r.destination === '0.0.0.0/0')
|
||||
return allOpen ? `出入全放行 · 入 ${ing} / 出 ${eg}` : `入 ${ing} / 出 ${eg}`
|
||||
}
|
||||
|
||||
const seclistColumns: DataTableColumns<SecurityList> = [
|
||||
{
|
||||
title: '安全列表',
|
||||
key: 'displayName',
|
||||
minWidth: 240,
|
||||
render: (row) => h('span', { class: 'font-medium' }, row.displayName),
|
||||
},
|
||||
{
|
||||
title: '规则',
|
||||
key: 'rules',
|
||||
minWidth: 180,
|
||||
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, ruleSummary(row)),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'lifecycleState',
|
||||
width: 120,
|
||||
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
|
||||
},
|
||||
]
|
||||
|
||||
const stepColumns: DataTableColumns<Ipv6Step> = [
|
||||
{
|
||||
title: '步骤',
|
||||
key: 'step',
|
||||
minWidth: 170,
|
||||
render: (r) => h('span', { class: 'mono text-[12.5px]' }, r.step),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
render: (r) =>
|
||||
h(StatusBadge, {
|
||||
kind: r.status === 'done' ? 'run' : r.status === 'skipped' ? 'check' : 'term',
|
||||
label: r.status,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
key: 'detail',
|
||||
minWidth: 260,
|
||||
render: (r) => h('span', { class: 'mono text-xs break-all' }, r.detail),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div
|
||||
v-if="vcn.loading.value && !vcn.data.value"
|
||||
class="panel flex items-center justify-center py-20"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div v-if="vcn.data.value" class="panel px-5 py-4">
|
||||
<RouterLink
|
||||
class="mb-2 inline-flex items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
|
||||
:to="{ name: 'network' }"
|
||||
>
|
||||
← 返回网络列表
|
||||
</RouterLink>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="page-title">{{ vcn.data.value.displayName }}</h1>
|
||||
<LifecycleBadge :state="vcn.data.value.lifecycleState" />
|
||||
<StatusBadge
|
||||
v-if="vcn.data.value.ipv6CidrBlocks?.length"
|
||||
kind="prov"
|
||||
label="IPv6 已启用"
|
||||
:dot="false"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<NButton size="small" @click="showRename = true">重命名</NButton>
|
||||
<NPopconfirm @positive-click="removeVcn">
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost :loading="removing" :disabled="removing">
|
||||
{{ removing ? '删除中…' : '删除 VCN' }}
|
||||
</NButton>
|
||||
</template>
|
||||
删除 VCN?将级联删除其子网、网关、路由等关联资源;子网仍被实例占用时会失败,需先终止实例
|
||||
</NPopconfirm>
|
||||
</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">
|
||||
<div class="text-xs text-ink-3">IPv4 CIDR</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ (vcn.data.value.cidrBlocks ?? []).join(', ') }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">IPv6 CIDR</div>
|
||||
<div class="mono mt-0.5 text-[13px] break-all">
|
||||
{{ (vcn.data.value.ipv6CidrBlocks ?? []).join(', ') || '未启用' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">DNS Label</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ vcn.data.value.dnsLabel || '—' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">创建时间</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ fmtTime(vcn.data.value.timeCreated) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5 text-sm font-semibold">子网</div>
|
||||
<NDataTable
|
||||
:columns="subnetColumns"
|
||||
:data="subnets.data.value ?? []"
|
||||
:loading="subnets.loading.value"
|
||||
:scroll-x="760"
|
||||
:row-key="(r: Subnet) => r.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5 text-sm font-semibold">安全列表</div>
|
||||
<NDataTable
|
||||
:columns="seclistColumns"
|
||||
:data="seclists.data.value ?? []"
|
||||
:loading="seclists.loading.value"
|
||||
: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>
|
||||
<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
|
||||
v-model:show="showRename"
|
||||
title="重命名 VCN"
|
||||
:current="vcn.data.value?.displayName ?? ''"
|
||||
:submitting="renaming"
|
||||
@submit="doRename"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import RFB from '@novnc/novnc'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/** 网页 VNC 独立页:从实例详情新标签页打开,独占整页无侧栏 */
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const cfgId = Number(route.params.cfgId)
|
||||
const instanceId = String(route.params.instanceId)
|
||||
const region = (route.query.region as string) || undefined
|
||||
const instanceName = (route.query.name as string) || instanceId
|
||||
|
||||
const screen = ref<HTMLDivElement | null>(null)
|
||||
const phase = ref<'idle' | 'creating' | 'connecting' | 'connected' | 'closed'>('idle')
|
||||
const error = ref('')
|
||||
// 适应窗口时 noVNC 按容器缩放画面;文本控制台缩放采样会糊 / 碎,可切原始 1:1
|
||||
const fitScreen = ref(true)
|
||||
let rfb: RFB | null = null
|
||||
let sessionId = ''
|
||||
|
||||
async function start() {
|
||||
phase.value = 'creating'
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await createConsoleSession(cfgId, instanceId, 'vnc', region)
|
||||
sessionId = res.sessionId
|
||||
} catch (e) {
|
||||
phase.value = 'closed'
|
||||
error.value = e instanceof Error ? e.message : '创建 VNC 会话失败'
|
||||
return
|
||||
}
|
||||
connect()
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!screen.value || !sessionId) return
|
||||
phase.value = 'connecting'
|
||||
rfb = new RFB(screen.value, consoleSessionWsUrl(sessionId, auth.token))
|
||||
rfb.scaleViewport = fitScreen.value
|
||||
rfb.background = 'transparent'
|
||||
// Tight JPEG 低质量压缩会把黑底控制台文本压成碎点噪声,固定最高质量
|
||||
rfb.qualityLevel = 9
|
||||
rfb.addEventListener('connect', () => {
|
||||
phase.value = 'connected'
|
||||
})
|
||||
rfb.addEventListener('disconnect', () => {
|
||||
if (phase.value !== 'closed') {
|
||||
phase.value = 'closed'
|
||||
if (!error.value) error.value = '连接已断开'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleFit() {
|
||||
fitScreen.value = !fitScreen.value
|
||||
if (rfb) rfb.scaleViewport = fitScreen.value
|
||||
}
|
||||
|
||||
function sendCad() {
|
||||
rfb?.sendCtrlAltDel()
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
cleanup()
|
||||
void start()
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
rfb?.disconnect()
|
||||
rfb = null
|
||||
if (sessionId) {
|
||||
void deleteConsoleSession(sessionId).catch(() => undefined)
|
||||
sessionId = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 关标签页时尽力删会话(keepalive 请求可在页面卸载后完成),TTL 15 分钟兜底 */
|
||||
function cleanupOnUnload() {
|
||||
if (!sessionId) return
|
||||
void fetch(`/api/v1/console-sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
keepalive: true,
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}).catch(() => undefined)
|
||||
sessionId = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.title = `${instanceName} · 网页 VNC`
|
||||
window.addEventListener('beforeunload', cleanupOnUnload)
|
||||
void start()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', cleanupOnUnload)
|
||||
cleanup()
|
||||
})
|
||||
</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-1" />
|
||||
<NButton size="tiny" :disabled="phase !== 'connected'" @click="toggleFit">
|
||||
{{ fitScreen ? '原始大小' : '适应窗口' }}
|
||||
</NButton>
|
||||
<NButton size="tiny" :disabled="phase !== 'connected'" @click="sendCad">Ctrl+Alt+Del</NButton>
|
||||
<NButton v-if="phase === 'closed'" size="tiny" type="primary" @click="reconnect">
|
||||
重新连接
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden bg-[#1c1b1a]">
|
||||
<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]"
|
||||
>
|
||||
<template v-if="phase === 'creating' || phase === 'connecting'">
|
||||
<NSpin size="small" />
|
||||
<div>
|
||||
{{ phase === 'creating' ? '正在准备控制台连接(约 30-90 秒)…' : '正在建立 VNC 隧道…' }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="phase === 'closed'">
|
||||
<div class="max-w-[70%] break-all text-center">{{ error || '连接已关闭' }}</div>
|
||||
<div class="text-xs">提示:创建会话时会自动清理实例上的旧控制台连接(串行 / VNC 同时只能开一个)</div>
|
||||
<div class="text-xs">画面黑屏或停在启动日志时,说明系统未输出到图形控制台,请改用串行控制台</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 全局限速解除后直接回到来路;无历史时回登录
|
||||
function retry() {
|
||||
window.location.replace('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden">
|
||||
<FullPageBackdrop tint="warn" rings="center" />
|
||||
<div class="relative z-10 flex flex-col items-center gap-2.5 px-6 text-center">
|
||||
<div class="chip bob"><span class="dot" style="background: var(--color-warn)"></span><span class="mono">429 Too Many Requests</span></div>
|
||||
<div class="bignum font-serif" style="color: var(--color-warn)">
|
||||
<span>4</span><span class="hollow floaty inline-block" style="-webkit-text-stroke-color: var(--color-warn)">2</span><span>9</span>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
<div class="meter mt-2.5">
|
||||
<span style="width: 72%; background: var(--color-ok); opacity: 0.55"></span>
|
||||
<span style="width: 14%; background: var(--color-warn); opacity: 0.75"></span>
|
||||
<span style="width: 14%; background: var(--color-err); opacity: 0.8"></span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center justify-between text-[10.5px] text-ink-3">
|
||||
<span>0</span><span>限速阈值</span><span class="mono">now</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-2.5">
|
||||
<NButton type="warning" @click="retry">稍后重试</NButton>
|
||||
<NButton @click="router.push({ name: 'login' })">返回登录</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./error-page.css"></style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden">
|
||||
<FullPageBackdrop tint="accent" rings="center" />
|
||||
<div class="relative z-10 flex flex-col items-center gap-2.5 px-6 text-center">
|
||||
<div class="chip bob"><span class="dot" style="background: var(--color-warn)"></span><span class="mono">404 Not Found</span></div>
|
||||
<div class="bignum font-serif">
|
||||
<span>4</span><span class="hollow floaty inline-block">0</span><span>4</span>
|
||||
</div>
|
||||
<div class="mt-1 font-serif text-[27px] font-semibold">这个页面不在任何区域</div>
|
||||
<div class="text-[13.5px] text-ink-2">地址已变更或从未存在,租户与实例都安好。</div>
|
||||
<div class="mt-6 flex gap-2.5">
|
||||
<NButton type="primary" @click="router.push({ name: 'overview' })">返回总览</NButton>
|
||||
<NButton @click="router.back()">上一页</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./error-page.css"></style>
|
||||
@@ -0,0 +1,70 @@
|
||||
/* 404 / 429 全屏错误页共享样式:状态徽章浮动 + 镂空巨字 */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 999px;
|
||||
background: var(--color-card);
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-2);
|
||||
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.07);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.bignum {
|
||||
display: flex;
|
||||
font-size: 250px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.bignum {
|
||||
font-size: 150px;
|
||||
}
|
||||
}
|
||||
.hollow {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 2.5px var(--color-ink);
|
||||
}
|
||||
.meter {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-wash-deep);
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.floaty {
|
||||
animation: err-fl 7s ease-in-out infinite;
|
||||
}
|
||||
.bob {
|
||||
animation: err-bob 6.3s ease-in-out -2.1s infinite;
|
||||
}
|
||||
@keyframes err-fl {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
@keyframes err-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
32% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
68% {
|
||||
transform: translateY(3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user