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

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+181
View File
@@ -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>
+294
View File
@@ -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>
+766
View File
@@ -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>
+229
View File
@@ -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>
+272
View File
@@ -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>
+30
View File
@@ -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>
+174
View File
@@ -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 默认启用 IPv6Oracle 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>
+243
View File
@@ -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>
+22
View File
@@ -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>
+631
View File
@@ -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&lt;token&gt;/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>
+323
View File
@@ -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>
+113
View File
@@ -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>
+165
View File
@@ -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>
+331
View File
@@ -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>
+308
View File
@@ -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>
+143
View File
@@ -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>
+49
View File
@@ -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>
+28
View File
@@ -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>
+70
View File
@@ -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);
}
}
}