发布 0.1.0:通知渠道、告警规则与多项体验修复
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
import { NButton } 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'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
|
||||
const keys = useAsync(listAiKeys)
|
||||
const channels = useAsync(listAiChannels)
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
NInputGroupLabel,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
@@ -24,6 +23,7 @@ import { fmtTime, shortAd, truncOcid, vpusLabel } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { BootVolume } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
interface BvRow extends BootVolume {
|
||||
cfgId: number
|
||||
@@ -31,7 +31,7 @@ interface BvRow extends BootVolume {
|
||||
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域 */
|
||||
const scope = useScopeStore()
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
|
||||
/** 当前作用域租户失联时整页替换占位,不发起资源请求 */
|
||||
const isDead = computed(() => scope.currentConfig?.aliveStatus === 'dead')
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
NPopconfirm,
|
||||
NSpin,
|
||||
useDialog,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
@@ -49,10 +48,11 @@ import {
|
||||
useTransientPoll,
|
||||
} from '@/composables/useTransientPoll'
|
||||
import type { BootVolume, ConsoleConnection, PowerAction, VolumeAttachment } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
@@ -133,10 +133,23 @@ const isStopped = computed(() => instance.data.value?.lifecycleState === 'STOPPE
|
||||
const isEnded = computed(() =>
|
||||
['TERMINATED', 'TERMINATING'].includes(instance.data.value?.lifecycleState ?? ''),
|
||||
)
|
||||
|
||||
// 区块数据首次加载中禁用对应头部按钮(loading && !data:静默轮询刷新不闪禁用态)
|
||||
const bvLoading = computed(
|
||||
() => (bvAtts.loading.value || bvDetails.loading.value) && !bvDetails.data.value,
|
||||
)
|
||||
const volLoading = computed(() => volAtts.loading.value && !volAtts.data.value)
|
||||
const consolesLoading = computed(() => consoles.loading.value && !consoles.data.value)
|
||||
const attachedVolumeIds = computed(() => (volAtts.data.value ?? []).map((a) => a.volumeId))
|
||||
|
||||
/** 创建时若选了随机密码,密码写在 TAG RootPassword 中,这里回显 */
|
||||
const rootPassword = computed(() => instance.data.value?.freeformTags?.RootPassword ?? '')
|
||||
// 密码默认掩码(S-01),点击查看后本页会话内保持可见
|
||||
const pwdRevealed = ref(false)
|
||||
|
||||
function revealRootPassword() {
|
||||
pwdRevealed.value = true
|
||||
}
|
||||
|
||||
async function copyRootPassword() {
|
||||
await navigator.clipboard.writeText(rootPassword.value)
|
||||
@@ -181,7 +194,6 @@ function openSerial() {
|
||||
instanceId: instanceId.value,
|
||||
region: region.value,
|
||||
instanceName: instance.data.value?.displayName,
|
||||
rootPassword: rootPassword.value,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -492,10 +504,28 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
>
|
||||
<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>
|
||||
<template v-if="pwdRevealed">
|
||||
<code class="mono truncate text-[13px]" :title="rootPassword">{{ rootPassword }}</code>
|
||||
<NButton size="tiny" quaternary type="primary" @click="copyRootPassword">
|
||||
复制
|
||||
</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<code class="mono text-[13px] tracking-widest select-none">••••••••</code>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
title="验证身份后查看"
|
||||
@click="revealRootPassword"
|
||||
>
|
||||
<svg class="mr-1 h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
查看
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 py-2.5 max-md:flex-col max-md:gap-0.5">
|
||||
@@ -593,7 +623,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
<div class="text-sm font-semibold">引导卷</div>
|
||||
<NButton
|
||||
size="small"
|
||||
:disabled="!isStopped || isEnded"
|
||||
:disabled="!isStopped || isEnded || bvLoading"
|
||||
:title="isStopped ? '' : '替换引导卷须实例处于 STOPPED'"
|
||||
@click="showReplaceBv = true"
|
||||
>
|
||||
@@ -661,7 +691,12 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
<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
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="isEnded || volLoading"
|
||||
@click="showAttachVol = true"
|
||||
>
|
||||
附加块卷
|
||||
</NButton>
|
||||
</div>
|
||||
@@ -682,11 +717,20 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
<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
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="isEnded || consolesLoading"
|
||||
@click="openSerial"
|
||||
>
|
||||
串行终端
|
||||
</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="openVnc">网页 VNC</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="showConsole = true">创建连接</NButton>
|
||||
<NButton size="small" :disabled="isEnded || consolesLoading" @click="openVnc">
|
||||
网页 VNC
|
||||
</NButton>
|
||||
<NButton size="small" :disabled="isEnded || consolesLoading" @click="showConsole = true">
|
||||
创建连接
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
NInputGroup,
|
||||
NInputGroupLabel,
|
||||
NSelect,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
@@ -22,13 +21,14 @@ 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'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
interface InstanceRow extends Instance {
|
||||
cfgId: number
|
||||
cfgAlias: string
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
const { alias } = useRegionAlias()
|
||||
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域,页内只做区间切换与搜索 */
|
||||
|
||||
+27
-1
@@ -8,12 +8,15 @@ import { ApiError } from '@/api/request'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
|
||||
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
|
||||
import { useThemeRipple } from '@/composables/useThemeRipple'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { OauthProviderInfo } from '@/types/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// 点击 logo 切换明暗主题,新主题自 logo 向外扩散
|
||||
const { toggleDarkFrom } = useThemeRipple()
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
@@ -123,7 +126,15 @@ async function oauthLogin(provider: string) {
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
class="floaty logo-btn flex-none"
|
||||
title="切换明暗主题"
|
||||
aria-label="切换明暗主题"
|
||||
@click="toggleDarkFrom"
|
||||
>
|
||||
<AppLogo :size="58" />
|
||||
</button>
|
||||
<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>
|
||||
@@ -240,6 +251,21 @@ async function oauthLogin(provider: string) {
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* logo 即主题开关:去按钮底色,悬停轻微放大提示可点 */
|
||||
.logo-btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
transition: scale 0.25s ease;
|
||||
}
|
||||
.logo-btn:hover {
|
||||
scale: 1.06;
|
||||
}
|
||||
.logo-btn:active {
|
||||
scale: 0.96;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.floaty {
|
||||
animation: login-fl 7s ease-in-out infinite;
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { aliveMeta } from '@/composables/useAliveStatus'
|
||||
import { fmtRelative } from '@/composables/useFormat'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
@@ -175,9 +176,15 @@ const currencySymbol = computed(() => {
|
||||
<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'"
|
||||
:class="
|
||||
c.aliveStatus === 'alive'
|
||||
? 'bg-ok'
|
||||
: c.aliveStatus === 'suspended'
|
||||
? 'bg-warn'
|
||||
: 'bg-err'
|
||||
"
|
||||
/>
|
||||
{{ c.aliveStatus === 'alive' ? '存活' : '失联' }}
|
||||
{{ aliveMeta(c.aliveStatus).label }}
|
||||
</div>
|
||||
<div class="w-18 flex-none text-right text-xs text-ink-3">{{ fmtRelative(c.lastVerifiedAt) }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NSpin, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
|
||||
import { NButton, NInput, NSpin, NSwitch, NTabPane, NTabs } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getSecuritySetting,
|
||||
getTaskSetting,
|
||||
getTelegramSetting,
|
||||
listNotifyChannels,
|
||||
listNotifyTemplates,
|
||||
testTelegram,
|
||||
updateNotifyEvents,
|
||||
@@ -20,27 +21,47 @@ 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 NotifyChannelCard from '@/components/settings/NotifyChannelCard.vue'
|
||||
import NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { NotifyEventsSetting, NotifyTemplateItem, SecuritySetting } from '@/types/api'
|
||||
import type {
|
||||
NotifyChannelItem,
|
||||
NotifyEventsSetting,
|
||||
NotifyTemplateItem,
|
||||
SecuritySetting,
|
||||
} from '@/types/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
|
||||
const tab = ref('notify')
|
||||
|
||||
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab
|
||||
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab;
|
||||
// 绑定使令牌版本递增,fragment 携带的新 token 须先落地,否则后续请求 401
|
||||
onMounted(() => {
|
||||
const { oauth, oauthError } = route.query
|
||||
if (oauth !== 'bound' && !oauthError) return
|
||||
consumeBindToken()
|
||||
tab.value = 'security'
|
||||
if (oauth === 'bound') message.success('外部身份绑定成功')
|
||||
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
|
||||
void router.replace({ query: {} })
|
||||
})
|
||||
|
||||
/** 消费绑定回跳 fragment 中的新会话 token(无感换发,不进服务端日志) */
|
||||
function consumeBindToken() {
|
||||
const hash = window.location.hash
|
||||
if (!hash.startsWith('#oauthToken=')) return
|
||||
const token = decodeURIComponent(hash.slice('#oauthToken='.length))
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
if (token) auth.setSession(token, new Date(Date.now() + 24 * 3600e3).toISOString())
|
||||
}
|
||||
|
||||
// ---- 通知管理:按事件类型的推送开关 ----
|
||||
const events = useAsync(getNotifyEvents)
|
||||
const eventForm = ref<NotifyEventsSetting>({
|
||||
@@ -138,6 +159,14 @@ async function saveEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 通知方式:多渠道(webhook/ntfy/bark/smtp)----
|
||||
const channels = useAsync(listNotifyChannels)
|
||||
|
||||
/** 渠道保存接口回全渠道最新视图,就地覆盖免二次拉取 */
|
||||
function onChannelsSaved(items: NotifyChannelItem[]) {
|
||||
channels.data.value = items
|
||||
}
|
||||
|
||||
// ---- 通知方式:Telegram 渠道配置 ----
|
||||
const setting = useAsync(getTelegramSetting)
|
||||
const enabled = ref(false)
|
||||
@@ -431,13 +460,24 @@ async function sendTest() {
|
||||
拉进群);③ 浏览器打开 api.telegram.org/bot<token>/getUpdates,响应中
|
||||
message.chat.id 即为 chat_id
|
||||
</FootNote>
|
||||
|
||||
<!-- 多渠道:与 Telegram 并存,可同时启用任意多个 -->
|
||||
<div class="border-t border-line-soft">
|
||||
<NotifyChannelCard
|
||||
v-for="ch in channels.data.value ?? []"
|
||||
:key="ch.type"
|
||||
:item="ch"
|
||||
@saved="onChannelsSaved"
|
||||
/>
|
||||
</div>
|
||||
</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 秒超时,失败仅记后端日志,不影响任务执行;同一事件按状态变化去重,
|
||||
不会刷屏。云端事件另受「日志回传」链路约束——未建链路的租户不产生该类通知。
|
||||
事件同时投递到全部已启用渠道;发送为异步、10 秒超时,单渠道失败仅记后端日志,
|
||||
不影响其他渠道与任务执行;同一事件按状态变化去重,不会刷屏。
|
||||
云端事件另受「日志回传」链路约束——未建链路的租户不产生该类通知。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, useDialog, useMessage } from 'naive-ui'
|
||||
import { NButton, NSpin, useDialog } from 'naive-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -12,10 +12,11 @@ import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/
|
||||
import { fmtRelative, fmtTime, regionCity } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, useDialog, useMessage } from 'naive-ui'
|
||||
import { NButton, NSpin, useDialog } from 'naive-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { deleteTask, listTasks, runTask, updateTask } from '@/api/tasks'
|
||||
@@ -10,8 +10,9 @@ import { parseSnatch } from '@/components/task/taskDisplay'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { Task } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NPopconfirm, NSpin, NTabPane, NTabs, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { NButton, NPopconfirm, NSelect, NSpin, NTabPane, NTabs, NTooltip } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { deleteConfig, getConfig, verifyConfig } from '@/api/configs'
|
||||
import { listIdentityDomains } from '@/api/tenant'
|
||||
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
|
||||
import AuditLogTab from '@/components/tenant/AuditLogTab.vue'
|
||||
import CostTab from '@/components/tenant/CostTab.vue'
|
||||
@@ -15,15 +16,17 @@ 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 { aliveMeta } from '@/composables/useAliveStatus'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const scope = useScopeStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
const cfgId = computed(() => Number(route.params.id))
|
||||
@@ -32,8 +35,33 @@ const verifying = ref(false)
|
||||
|
||||
const config = useAsync(() => getConfig(cfgId.value))
|
||||
|
||||
/** 失联租户各 tab 拉不到云端数据,内容区整体换失联占位 */
|
||||
const isDead = computed(() => config.data.value?.aliveStatus === 'dead')
|
||||
/** 失联/暂停租户各 tab 拉不到云端数据,内容区整体换占位 */
|
||||
const isBlocked = computed(() =>
|
||||
['dead', 'suspended'].includes(config.data.value?.aliveStatus ?? ''),
|
||||
)
|
||||
|
||||
// ---- 身份域:用户 / IDP / 其他 三个 tab 共享的域上下文 ----
|
||||
// 拉取失败(无权限/老租户)静默降级:不显示选择器,各 tab 走缺省 Default 路径
|
||||
const domains = useAsync(async () => {
|
||||
try {
|
||||
return await listIdentityDomains(cfgId.value)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const domainId = ref<string | undefined>(undefined)
|
||||
watch(domains.data, (list) => {
|
||||
if (!list?.length) return
|
||||
const preferred = list.find((d) => d.displayName === 'Default') ?? list[0]
|
||||
domainId.value ??= preferred.id
|
||||
})
|
||||
const domainOptions = computed(
|
||||
() => domains.data.value?.map((d) => ({ label: d.displayName, value: d.id })) ?? [],
|
||||
)
|
||||
/** 域选择器只在多域租户的域相关 tab 显示 */
|
||||
const showDomainPicker = computed(
|
||||
() => (domains.data.value?.length ?? 0) > 1 && ['users', 'idp', 'misc'].includes(tab.value),
|
||||
)
|
||||
|
||||
async function verify() {
|
||||
verifying.value = true
|
||||
@@ -53,7 +81,7 @@ async function verify() {
|
||||
async function remove() {
|
||||
try {
|
||||
await deleteConfig(cfgId.value)
|
||||
message.success('已删除(仅移除本面板配置,不影响云端资源)')
|
||||
message.success('已删除租户及关联本地数据,OCI 云端资源未受影响')
|
||||
void scope.refreshConfigs()
|
||||
void router.push({ name: 'tenants' })
|
||||
} catch (e) {
|
||||
@@ -88,19 +116,22 @@ async function remove() {
|
||||
<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"
|
||||
v-if="isBlocked && config.data.value.lastError"
|
||||
trigger="hover"
|
||||
placement="top-start"
|
||||
>
|
||||
<template #trigger>
|
||||
<StatusBadge kind="term" label="失联" />
|
||||
<StatusBadge
|
||||
:kind="aliveMeta(config.data.value.aliveStatus).kind"
|
||||
:label="aliveMeta(config.data.value.aliveStatus).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' ? '存活' : '失联'"
|
||||
:kind="aliveMeta(config.data.value.aliveStatus).kind"
|
||||
:label="aliveMeta(config.data.value.aliveStatus).label"
|
||||
/>
|
||||
<AccountTypeBadge :type="config.data.value.accountType" />
|
||||
<div class="flex-1" />
|
||||
@@ -109,7 +140,8 @@ async function remove() {
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost>删除租户</NButton>
|
||||
</template>
|
||||
删除「{{ config.data.value.alias }}」?仅移除本面板配置与快照,不影响云端资源。
|
||||
删除「{{ config.data.value.alias }}」?将清理本面板中的关联任务、快照、回传事件与 Webhook
|
||||
密钥、告警及 AI 渠道数据;OCI 云端资源不会被删除(云端日志回传链路请先在“其他”页销毁)。
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
|
||||
@@ -140,19 +172,30 @@ async function remove() {
|
||||
<NTabPane name="subs" tab="订阅" />
|
||||
<NTabPane name="audit" tab="审计日志" />
|
||||
<NTabPane name="misc" tab="其他" />
|
||||
<template #suffix>
|
||||
<!-- 多域租户:用户/IDP/其他 tab 按选中域读写 -->
|
||||
<div v-if="showDomainPicker" class="w-64 max-md:w-44">
|
||||
<NSelect v-model:value="domainId" size="small" :options="domainOptions" placeholder="身份域" />
|
||||
</div>
|
||||
</template>
|
||||
</NTabs>
|
||||
</div>
|
||||
|
||||
<template v-if="config.data.value">
|
||||
<DeadPlaceholder v-if="isDead" :last-error="config.data.value.lastError" />
|
||||
<DeadPlaceholder
|
||||
v-if="isBlocked"
|
||||
:last-error="config.data.value.lastError"
|
||||
:suspended="config.data.value.aliveStatus === 'suspended'"
|
||||
/>
|
||||
<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" />
|
||||
<!-- 域相关 tab 以 domainId 为 key:切域整组件重挂载,状态与数据全量刷新 -->
|
||||
<IdpTab v-else-if="tab === 'idp'" :key="`idp-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
|
||||
<UsersTab v-else-if="tab === 'users'" :key="`users-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
|
||||
<CostTab v-else-if="tab === 'cost'" :cfg-id="cfgId" />
|
||||
<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" />
|
||||
<MiscTab v-else :key="`misc-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NPopconfirm, NPopover, NSelect, NTooltip, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NPopconfirm, NPopover, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, ref, type VNodeChild } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
@@ -11,13 +11,15 @@ 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 { aliveMeta } from '@/composables/useAliveStatus'
|
||||
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'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
const router = useRouter()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
const scope = useScopeStore()
|
||||
@@ -104,7 +106,7 @@ function setGlobal(row: OciConfigSummary) {
|
||||
async function remove(row: OciConfigSummary) {
|
||||
try {
|
||||
await deleteConfig(row.id)
|
||||
message.success(`已删除「${row.alias}」(仅移除本面板配置,不影响云端资源)`)
|
||||
message.success(`已删除「${row.alias}」及关联本地数据,OCI 云端资源未受影响`)
|
||||
reloadConfigs()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
@@ -125,13 +127,11 @@ function renderTenant(row: OciConfigSummary): VNodeChild {
|
||||
])
|
||||
}
|
||||
|
||||
/** 状态列:仅徽标;失联且有错误详情时 hover 徽标以 tooltip 展示完整 lastError */
|
||||
/** 状态列:仅徽标;失联/暂停且有错误详情时 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
|
||||
const meta = aliveMeta(row.aliveStatus)
|
||||
const badge = h(StatusBadge, { kind: meta.kind, label: meta.label })
|
||||
if (!['dead', 'suspended'].includes(row.aliveStatus) || !row.lastError) return badge
|
||||
return h(
|
||||
NTooltip,
|
||||
{ trigger: 'hover', placement: 'top-start' },
|
||||
@@ -221,7 +221,8 @@ function renderActions(row: OciConfigSummary): VNodeChild {
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
|
||||
default: () => `删除「${row.alias}」?仅移除本面板配置与快照,不影响云端资源。`,
|
||||
default: () =>
|
||||
`删除「${row.alias}」?将清理本面板中的关联任务、快照、回传事件与 Webhook 密钥、告警及 AI 渠道数据;OCI 云端资源不会被删除(云端日志回传链路请先在“其他”页销毁)。`,
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
NDataTable,
|
||||
NPopconfirm,
|
||||
NSpin,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref } from 'vue'
|
||||
@@ -18,10 +17,11 @@ import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { Ipv6Step, SecurityList, Subnet } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const message = useToast()
|
||||
|
||||
const cfgId = computed(() => Number(route.params.cfgId))
|
||||
const vcnId = computed(() => String(route.params.vcnId))
|
||||
|
||||
Reference in New Issue
Block a user