302 lines
11 KiB
Vue
302 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { NButton, NSelect, NSpin, NTabPane, NTabs, NTooltip } from 'naive-ui'
|
|
import { computed, onUnmounted, 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 ConfirmPop from '@/components/ConfirmPop.vue'
|
|
import EmptyCard from '@/components/EmptyCard.vue'
|
|
import CostTab from '@/components/tenant/CostTab.vue'
|
|
import InvoiceTab from '@/components/tenant/InvoiceTab.vue'
|
|
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
|
import IdpTab from '@/components/tenant/IdpTab.vue'
|
|
import MiscTab from '@/components/tenant/MiscTab.vue'
|
|
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 { aliveMeta } from '@/composables/useAliveStatus'
|
|
import { fmtTime } from '@/composables/useFormat'
|
|
import { useIsMobile } from '@/composables/useIsMobile'
|
|
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 = useToast()
|
|
const { alias: regionAlias } = useRegionAlias()
|
|
|
|
const cfgId = computed(() => Number(route.params.id))
|
|
const tab = ref('quota')
|
|
const verifying = ref(false)
|
|
|
|
/** 移动端 hero 元信息默认收起省首屏;域选择器移出 Tab suffix 独立成行 */
|
|
const isMobile = useIsMobile()
|
|
const heroExpanded = ref(false)
|
|
|
|
const config = useAsync(() => getConfig(cfgId.value))
|
|
|
|
function refreshAfterKeyActivation() {
|
|
void config.run()
|
|
void scope.refreshConfigs()
|
|
}
|
|
|
|
/** 新 key 激活后的 OCI 公钥全球传播窗口:窗口内显示提示横幅,到点自动消失 */
|
|
const KEY_PROPAGATION_MS = 15 * 60 * 1000
|
|
const propagationNow = ref(Date.now())
|
|
let propagationTimer = 0
|
|
const keyPropagating = computed(() => {
|
|
const at = config.data.value?.keyActivatedAt
|
|
return !!at && propagationNow.value - new Date(at).getTime() < KEY_PROPAGATION_MS
|
|
})
|
|
watch(
|
|
() => config.data.value?.keyActivatedAt,
|
|
(at) => {
|
|
window.clearTimeout(propagationTimer)
|
|
if (!at) return
|
|
const left = new Date(at).getTime() + KEY_PROPAGATION_MS - Date.now()
|
|
if (left > 0)
|
|
propagationTimer = window.setTimeout(() => (propagationNow.value = Date.now()), left + 1000)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
onUnmounted(() => window.clearTimeout(propagationTimer))
|
|
|
|
/** 失联/暂停租户各 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
|
|
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('已删除租户及关联本地数据,OCI 云端资源未受影响')
|
|
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">
|
|
<EmptyCard title="租户加载失败" :note="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="isBlocked && config.data.value.lastError"
|
|
trigger="hover"
|
|
placement="top-start"
|
|
>
|
|
<template #trigger>
|
|
<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="aliveMeta(config.data.value.aliveStatus).kind"
|
|
:label="aliveMeta(config.data.value.aliveStatus).label"
|
|
/>
|
|
<AccountTypeBadge :type="config.data.value.accountType" />
|
|
<div class="flex-1" />
|
|
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
|
<div class="flex items-center gap-2 max-md:w-full">
|
|
<NButton size="small" :loading="verifying" @click="verify">立即测活</NButton>
|
|
<ConfirmPop
|
|
:title="`删除「${config.data.value.alias}」?`"
|
|
content="将清理面板内关联数据。"
|
|
note="云端日志回传链路请先在「其他」页销毁"
|
|
confirm-text="删除"
|
|
placement="bottom-end"
|
|
:on-confirm="remove"
|
|
>
|
|
<template #trigger>
|
|
<NButton size="small" type="error" ghost>删除租户</NButton>
|
|
</template>
|
|
</ConfirmPop>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="keyPropagating"
|
|
class="mt-3 flex items-start gap-2 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2.5 text-xs leading-relaxed"
|
|
>
|
|
<svg
|
|
class="mt-0.5 h-[14px] w-[14px] flex-none text-warn"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M12 8v4M12 16h.01" />
|
|
</svg>
|
|
<span>
|
|
新 API Key 正在全球生效中(约需数分钟):期间部分区域可能间歇报
|
|
NotAuthenticated,属预期现象,请稍后重试
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
v-if="isMobile"
|
|
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
|
@click="heroExpanded = !heroExpanded"
|
|
>
|
|
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
|
<svg
|
|
class="h-3 w-3 transition-transform"
|
|
:class="heroExpanded ? 'rotate-180' : ''"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
<div
|
|
v-if="!isMobile || heroExpanded"
|
|
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>
|
|
<div v-if="isMobile" class="pt-2"></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="invoice" tab="发票" />
|
|
<NTabPane name="subs" tab="订阅" />
|
|
<NTabPane name="audit" tab="审计日志" />
|
|
<NTabPane name="misc" tab="其他" />
|
|
<template v-if="!isMobile" #suffix>
|
|
<!-- 多域租户:用户/IDP/其他 tab 按选中域读写 -->
|
|
<div v-if="showDomainPicker" class="w-64">
|
|
<NSelect v-model:value="domainId" size="small" :options="domainOptions" placeholder="身份域" />
|
|
</div>
|
|
</template>
|
|
</NTabs>
|
|
<!-- 移动端:域选择器独立成行,不与 Tab 条抢横向空间 -->
|
|
<div v-if="isMobile && showDomainPicker" class="flex items-center gap-2 pt-2.5 pb-3">
|
|
<span class="flex-none text-xs text-ink-3">身份域</span>
|
|
<NSelect v-model:value="domainId" size="small" :options="domainOptions" placeholder="身份域" />
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="config.data.value">
|
|
<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" />
|
|
<!-- 域相关 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"
|
|
@key-activated="refreshAfterKeyActivation"
|
|
/>
|
|
<CostTab v-else-if="tab === 'cost'" :cfg-id="cfgId" />
|
|
<InvoiceTab v-else-if="tab === 'invoice'" :cfg-id="cfgId" />
|
|
<SubsTab v-else-if="tab === 'subs'" :cfg-id="cfgId" :account-type="config.data.value.accountType" />
|
|
<AuditLogTab v-else-if="tab === 'audit'" :cfg-id="cfgId" />
|
|
<MiscTab v-else :key="`misc-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
|
|
</template>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.tenant-tabs :deep(.n-tabs-nav) {
|
|
overflow-x: auto;
|
|
}
|
|
</style>
|