初始提交: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
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { NButton, NDataTable, NModal, NSelect, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { getAuditEventDetail, listAuditEvents } from '@/api/tenant'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { eventLabel } from '@/composables/useEventLabel'
import { fmtTime } from '@/composables/useFormat'
import type { AuditEvent } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const hours = ref(24)
const hourOptions = [
{ label: '近 1 小时', value: 1 },
{ label: '近 6 小时', value: 6 },
{ label: '近 24 小时', value: 24 },
{ label: '近 72 小时', value: 72 },
]
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
const pagination = reactive({
page: 1,
pageSize: 20,
showSizePicker: true,
pageSizes: [20, 50, 100],
onUpdatePage: (p: number) => {
pagination.page = p
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
},
})
const loading = ref(false)
const loadingMore = ref(false)
const error = ref('')
const rows = ref<AuditEvent[]>([])
const truncated = ref(false)
const nextPage = ref('')
// 本次查询的绝对时间窗:续查游标绑定查询参数,必须原样带回
const win = ref<{ start: string; end: string } | null>(null)
async function load() {
loading.value = true
error.value = ''
try {
const r = await listAuditEvents(props.cfgId, { hours: hours.value })
rows.value = r.items
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
win.value = { start: r.start, end: r.end }
pagination.page = 1
} catch (e) {
error.value = e instanceof Error ? e.message : '查询失败'
} finally {
loading.value = false
}
}
/** 截断续查:同一绝对窗带游标断点续翻,追加去重后按时间重排 */
async function loadMore() {
if (!win.value || !nextPage.value || loadingMore.value) return
loadingMore.value = true
try {
const r = await listAuditEvents(props.cfgId, { ...win.value, page: nextPage.value })
const seen = new Set(rows.value.map((e) => e.eventId))
rows.value = [...rows.value, ...r.items.filter((e) => !seen.has(e.eventId))].sort((a, b) =>
(b.eventTime ?? '').localeCompare(a.eventTime ?? ''),
)
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
} catch (e) {
// 追加失败不清空已有列表;游标过期时提示重新查询
message.error(e instanceof Error ? e.message : '继续加载失败,请刷新重新查询')
} finally {
loadingMore.value = false
}
}
onMounted(() => void load())
// 切换时间窗自动重查并回到第一页
watch(hours, () => void load())
// ---- 行点击详情:原始 JSON 懒取(命中服务端缓存秒开,过期走小窗重查) ----
const detail = ref<AuditEvent | null>(null)
const showDetail = ref(false)
const detailRaw = ref<unknown>(null)
const rawLoading = ref(false)
const rawGone = ref(false)
function rowProps(row: AuditEvent) {
return {
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
void loadRaw(row)
},
}
}
async function loadRaw(row: AuditEvent) {
detailRaw.value = null
rawGone.value = false
if (!row.eventId || !row.eventTime) {
rawGone.value = true
return
}
rawLoading.value = true
try {
const { raw } = await getAuditEventDetail(props.cfgId, {
eventId: row.eventId,
eventTime: row.eventTime,
})
detailRaw.value = raw
} catch {
rawGone.value = true
} finally {
rawLoading.value = false
}
}
/** 摘要头 chips:操作者 / IP / 时间;完整长值仍在字段网格与原始 JSON 中 */
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '操作者', value: d.principalName || '—' },
{ label: 'IP', value: d.ipAddress || '—', mono: true },
{ label: '时间', value: fmtTime(d.eventTime) },
]
})
/** 字段网格:头部未覆盖的补充字段;空值以「—」占位 */
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '资源', value: d.resourceName, mono: true },
{ label: '区间', value: d.compartmentName },
{ label: '请求', value: `${d.requestAction} ${d.requestPath}`.trim(), mono: true },
{ label: '来源', value: d.source },
]
})
/** 主文本 + 次行小字的两行单元格;空值以「—」占位 */
function twoLine(main: string, sub: string) {
return h('div', { class: 'min-w-0' }, [
h('div', { class: 'truncate text-[13px]' }, main || '—'),
h('div', { class: 'mt-px truncate text-xs text-ink-3' }, sub || '—'),
])
}
const columns: DataTableColumns<AuditEvent> = [
{
title: '时间',
key: 'eventTime',
width: 145,
render: (row) =>
h('span', { class: 'text-[13px] text-ink-2 tabular-nums whitespace-nowrap' }, fmtTime(row.eventTime)),
},
{
title: '操作',
key: 'eventName',
minWidth: 200,
render: (row) => twoLine(row.eventName, row.source),
},
{
// 固定列宽让 truncate 生效:资源路径可长达数百字符,完整值点行查看
title: '资源',
key: 'resourceName',
width: 200,
render: (row) => twoLine(row.resourceName, row.compartmentName),
},
{
title: '操作者',
key: 'principalName',
width: 180,
render: (row) => twoLine(row.principalName, row.ipAddress),
},
{
title: '结果',
key: 'status',
width: 90,
render: (row) => {
if (!row.status) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
const failed = Number(row.status) >= 400
return h(StatusBadge, { kind: failed ? 'term' : 'run', label: row.status, dot: false })
},
},
]
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3">
<div class="text-sm font-semibold">审计日志</div>
<template v-if="truncated">
<StatusBadge kind="warn" label="结果已截断" />
<NButton v-if="nextPage" size="tiny" :loading="loadingMore" @click="loadMore">
继续加载更早
</NButton>
</template>
<div class="flex-1" />
<div class="w-32 max-md:w-full">
<NSelect v-model:value="hours" size="small" :options="hourOptions" />
</div>
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
</div>
<div
v-if="error"
class="px-5 py-10 text-center text-[13px] text-ink-3"
>
{{ error }}
</div>
<NDataTable
v-else
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:scroll-x="820"
:max-height="480"
:row-props="rowProps"
/>
<FootNote>
OCI Audit 免费且默认开启事件保留 365 无需在云端做任何配置 ·
实时查询不入库事件通常延迟数分钟可见 ·
已默认过滤遥测噪声SummarizeMetricsData与内网地址发起的服务互调 ·
单次最多翻 10 截断时点继续加载更早断点续查 · 点击行查看完整详情
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
v-if="detail"
kind="审计事件 · OCI Audit"
:title="eventLabel(detail.eventName) || detail.eventName"
:sub="`${detail.eventName} · ${detail.source}`"
:chips="heroChips"
>
<StatusBadge
v-if="detail.status"
:kind="Number(detail.status) >= 400 ? 'term' : 'run'"
:label="`${Number(detail.status) >= 400 ? '失败' : '成功'} ${detail.status}`"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<JsonBlock
v-if="detailRaw"
:value="detailRaw"
title="原始 JSON"
meta="来自 Audit SDK"
max-height="36vh"
wide
/>
<div v-else-if="rawLoading" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
正在取回原始事件
</div>
<div v-else-if="rawGone" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
原始事件已不可取回超出缓存与重查窗口以上为列表精简字段
</div>
</div>
</NModal>
</div>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { NSelect, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { getCosts } from '@/api/analytics'
import CostChart from '@/components/CostChart.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{ cfgId: number }>()
const rangeDays = ref(14)
const rangeOptions = [
{ label: '近 14 天', value: 14 },
{ label: '近 30 天', value: 30 },
]
const costs = useAsync(() => getCosts(props.cfgId, { granularity: 'DAILY', groupBy: 'service' }))
watch(rangeDays, () => void costs.run())
const daily = computed(() => {
const byDay = new Map<string, number>()
for (const item of costs.data.value ?? []) {
const day = item.timeStart.slice(5, 10)
byDay.set(day, (byDay.get(day) ?? 0) + item.computedAmount)
}
const labels = [...byDay.keys()].sort()
return { labels, values: labels.map((l) => +(byDay.get(l) ?? 0).toFixed(2)) }
})
const total = computed(() => daily.value.values.reduce((s, v) => s + v, 0))
const byService = computed(() => {
const map = new Map<string, number>()
for (const item of costs.data.value ?? []) {
map.set(item.groupValue, (map.get(item.groupValue) ?? 0) + item.computedAmount)
}
const rows = [...map.entries()].sort((a, b) => b[1] - a[1])
const max = rows[0]?.[1] ?? 1
return rows.map(([name, amount]) => ({
name,
amount,
pct: total.value ? Math.round((amount / total.value) * 100) : 0,
barWidth: `${Math.round((amount / max) * 100)}%`,
}))
})
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">成本分析</div>
<div class="flex-1" />
<div class="w-28">
<NSelect v-model:value="rangeDays" size="small" :options="rangeOptions" />
</div>
</div>
<div v-if="costs.loading.value" class="flex items-center justify-center py-14">
<NSpin size="small" />
</div>
<template v-else-if="costs.data.value?.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">${{ total.toFixed(2) }}</div>
<div class="text-[12.5px] text-ink-3">
{{ daily.labels[0] }} {{ daily.labels.at(-1) }} 合计
</div>
</div>
<div class="px-3 pb-1">
<CostChart :labels="daily.labels" :values="daily.values" />
</div>
<div class="border-t border-line-soft px-4.5 py-3">
<div class="mb-2 flex items-center justify-between">
<div class="text-[13px] font-semibold">按服务拆分</div>
<div class="text-[12.5px] text-ink-3">本期合计 ${{ total.toFixed(2) }}</div>
</div>
<div
v-for="svc in byService"
:key="svc.name"
class="flex items-center gap-3 py-1.5 max-md:gap-2"
>
<div class="w-32 flex-none truncate text-[13px] max-md:w-24" :title="svc.name">
{{ svc.name }}
</div>
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-line-soft">
<div class="h-full rounded-full bg-chart" :style="{ width: svc.barWidth }" />
</div>
<div class="w-17 flex-none text-right text-[13px] tabular-nums">
${{ svc.amount.toFixed(2) }}
</div>
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">{{ svc.pct }}%</div>
</div>
</div>
</template>
<EmptyCard
v-else
title="暂无成本数据"
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天"
/>
<FootNote>
每日 03:30 自动同步 Usage API · 本地快照 · 免费类别租户不参与成本同步 · 点击刷新可即时拉取
</FootNote>
</div>
</template>
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { NCheckbox, NInput, NSelect, NSwitch, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
const props = defineProps<{ show: boolean; cfgId: number }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
const primaryEmailRequired = ref(false)
const nameIdFormatOptions = [
{ label: '无', value: 'saml-none' },
{ label: '电子邮件地址', value: 'saml-emailaddress' },
{ label: '持久标识符', value: 'saml-persistent' },
{ label: '临时标识符', value: 'saml-transient' },
{ label: '未指定', value: 'saml-unspecified' },
]
const idpAttrOptions = [
{ label: 'SAML 断言名称 IDNameID', value: 'nameid' },
{ label: '断言属性', value: 'attribute' },
]
const userAttrOptions = [
{ label: '用户名(userName', value: 'userName' },
{ label: '主电子邮件(emails.primary.value', value: 'emails.primary.value' },
]
// 默认值与控制台一致:名称 ID 格式「无」、NameID 映射用户名、
// JIT 开启建用户不更新、分配 Administrators 组
const blank = {
name: '',
metadata: '',
description: '',
iconUrl: '',
nameIdFormat: 'saml-none',
idpUserAttr: 'nameid',
assertionAttr: '',
userStoreAttribute: 'userName',
jitEnabled: true,
jitCreate: true,
jitUpdate: false,
jitAssignAdminGroup: true,
jitMapEmail: false,
}
const form = reactive({ ...blank })
watch(
() => props.show,
(v) => {
if (!v) return
Object.assign(form, blank)
void loadIdentitySetting()
},
)
async function loadIdentitySetting() {
try {
const s = await getIdentitySetting(props.cfgId)
primaryEmailRequired.value = s.primaryEmailRequired
if (s.primaryEmailRequired) form.jitMapEmail = true
} catch {
primaryEmailRequired.value = false
}
}
// IDCS 只接受 http(s) 外链图标,data URI 会被拒(error.saml.identityprovider.invalidIconUrl
const iconUrlValid = computed(() => {
const v = form.iconUrl.trim()
return !v || /^https?:\/\//.test(v)
})
const canSubmit = computed(
() =>
!!form.name.trim() &&
form.metadata.includes('EntityDescriptor') &&
iconUrlValid.value &&
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
)
function buildBody(): CreateIdpBody {
return {
name: form.name.trim(),
metadata: form.metadata,
description: form.description.trim() || undefined,
iconUrl: form.iconUrl.trim() || undefined,
nameIdFormat: form.nameIdFormat,
assertionAttribute: form.idpUserAttr === 'attribute' ? form.assertionAttr.trim() : undefined,
userStoreAttribute: form.userStoreAttribute,
jitEnabled: form.jitEnabled,
jitCreate: form.jitCreate,
jitUpdate: form.jitUpdate,
jitAssignAdminGroup: form.jitAssignAdminGroup,
jitMapEmail: form.jitEnabled && form.jitMapEmail,
}
}
async function submit() {
submitting.value = true
try {
const created = await createIdp(props.cfgId, buildBody())
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="创建 SAML IdP"
:width="640"
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="创建禁用态"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField label="名称" required>
<NInput v-model:value="form.name" placeholder="my-idp" />
</FormField>
<FormField
label="IdP SAML metadata XML 文件"
required
hint="IdP 侧导出的 metadata XML;先在本页「下载 SP 元数据」导入 IdP 侧再创建"
:error="
form.metadata && !form.metadata.includes('EntityDescriptor')
? '文件内容不含 EntityDescriptor,请确认选择的是 SAML metadata XML'
: undefined
"
:feedback="
form.metadata.includes('EntityDescriptor')
? `已读取 ${(form.metadata.length / 1024).toFixed(1)} KB`
: undefined
"
>
<FilePicker
:key="`md-${show}`"
label="拖拽或点击上传 SAML metadata 文件"
hint="支持 .xml,文件内容仅本地读取"
accept=".xml"
@load="(text) => (form.metadata = text)"
/>
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="备注">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
<FormField
label="Logo(登录页图标)"
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传"
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
>
<div class="flex items-center gap-2">
<img
v-if="form.iconUrl && iconUrlValid"
:src="form.iconUrl"
alt=""
class="h-7 w-7 flex-none rounded border border-line-soft object-contain"
/>
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
</div>
</FormField>
</div>
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
映射用户身份
</div>
<FormField label="请求的名称 ID 格式" hint="向 IdP 请求断言时期望的 NameID 格式">
<NSelect v-model:value="form.nameIdFormat" :options="nameIdFormatOptions" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="IdP 用户属性" hint="从 SAML 断言中取哪个值来识别用户">
<NSelect v-model:value="form.idpUserAttr" :options="idpAttrOptions" />
</FormField>
<FormField label="域用户属性" hint="用取到的值匹配身份域中用户的哪个属性">
<NSelect v-model:value="form.userStoreAttribute" :options="userAttrOptions" />
</FormField>
</div>
<FormField v-if="form.idpUserAttr === 'attribute'" label="断言属性名" required>
<NInput v-model:value="form.assertionAttr" placeholder="如 mail、uid" />
</FormField>
<div class="mt-1 mb-2 flex items-center justify-between border-t border-line-soft pt-3">
<span class="text-[12.5px] font-semibold">JIT 预配</span>
<NSwitch v-model:value="form.jitEnabled" size="small" />
</div>
<div v-if="form.jitEnabled" class="mb-2 flex flex-col gap-1.5">
<NCheckbox v-model:checked="form.jitCreate">首次联邦登录时自动创建用户</NCheckbox>
<NCheckbox v-model:checked="form.jitUpdate">每次登录时按断言更新既有用户</NCheckbox>
<NCheckbox v-model:checked="form.jitAssignAdminGroup">
JIT 用户加入 Administrators
</NCheckbox>
<NCheckbox
v-model:checked="form.jitMapEmail"
:disabled="primaryEmailRequired"
>
映射邮箱属性断言 email 主电子邮件
<span v-if="primaryEmailRequired" class="text-xs text-ink-3">
域已开启用户需要提供主电子邮件地址必须映射
</span>
</NCheckbox>
</div>
<div class="text-xs leading-relaxed text-ink-3">
创建后为禁用态需手动激活映射与 JIT 均按上方配置写入身份域
</div>
</FormModal>
</template>
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, NSwitch, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import {
activateIdp,
createSignOnExemption,
deleteIdp,
deleteSignOnExemption,
downloadSamlMetadata,
listIdps,
listSignOnRules,
} from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import IdpFormModal from '@/components/tenant/IdpFormModal.vue'
import { useAsync } from '@/composables/useAsync'
import type { IdentityProvider, SignOnRule } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const dialog = useDialog()
const idps = useAsync(() => listIdps(props.cfgId))
const rules = useAsync(() => listSignOnRules(props.cfgId))
const showCreate = ref(false)
async function act(fn: () => Promise<unknown>, ok: string) {
try {
await fn()
message.success(ok)
void idps.run()
void rules.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
const idpColumns: DataTableColumns<IdentityProvider> = [
{
title: 'IdP',
key: 'name',
minWidth: 220,
render: (row) =>
h('div', [
h('div', { class: 'font-medium' }, row.name),
h('div', { class: 'mono text-xs text-ink-3 mt-px break-all' }, row.partnerProviderId),
]),
},
{ title: '协议', key: 'type', width: 80 },
{
title: '状态',
key: 'enabled',
width: 100,
render: (row) =>
h(StatusBadge, {
kind: row.enabled ? 'run' : 'stop',
label: row.enabled ? '已启用' : '已停用',
}),
},
{
title: 'JIT',
key: 'jitEnabled',
width: 70,
render: (row) => h('span', { class: 'text-[13px]' }, row.jitEnabled ? '开启' : '关闭'),
},
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { class: 'flex items-center gap-3' }, [
h(NSwitch, {
size: 'small',
value: row.enabled,
onUpdateValue: (v: boolean) =>
act(() => activateIdp(props.cfgId, row.id, v), v ? '已激活并显示到登录页' : '已停用'),
}),
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDeleteIdp(row) },
{ default: () => '删除' },
),
]),
},
]
const ruleColumns: DataTableColumns<SignOnRule> = [
{ title: '#', key: 'sequence', width: 50 },
{
title: '规则',
key: 'name',
minWidth: 180,
render: (row) =>
h('div', [
h('div', { class: 'mono text-[12.5px]' }, row.name),
h(
'div',
{ class: 'text-xs text-ink-3 mt-px' },
row.conditionAttribute ? `${row.conditionAttribute} in ${row.conditionValue}` : '默认兜底规则',
),
]),
},
{
title: '认证因子',
key: 'authenticationFactor',
width: 120,
render: (row) =>
h(StatusBadge, {
kind: row.authenticationFactor === 'IDP' ? 'prov' : 'check',
label: row.authenticationFactor === 'IDP' ? '仅 IdP' : 'IdP + 2FA',
dot: false,
}),
},
{
title: '操作',
key: 'actions',
width: 110,
render: (row) =>
row.builtIn
? h('span', { class: 'text-xs text-ink-3' }, '内置')
: h(
NPopconfirm,
{
onPositiveClick: () =>
act(() => deleteSignOnExemption(props.cfgId, row.id), '已删除免 MFA 规则并恢复优先级'),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
default: () => '删除该免 MFA 规则?删除后经此 IdP 登录将重新要求 MFA',
},
),
},
]
async function doDownloadMetadata() {
try {
await downloadSamlMetadata(props.cfgId)
message.success('SAML 元数据已下载')
} catch (e) {
message.error(e instanceof Error ? e.message : '下载失败')
}
}
function confirmDeleteIdp(row: IdentityProvider) {
dialog.warning({
title: '删除身份提供商',
content: () =>
h('div', { class: 'text-[13px]' }, [
h('p', `确定删除 IdP「${row.name}」?此操作不可恢复。`),
h('p', { class: 'mt-1.5 text-ink-3' }, '将自动清理关联的免 MFA 规则,并从登录页移除'),
]),
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () =>
act(() => deleteIdp(props.cfgId, row.id), `已删除 IdP「${row.name}」及其关联规则`),
})
}
function addExemption() {
const enabled = idps.data.value?.find((i) => i.enabled)
if (!enabled) {
message.warning('没有已启用的 IdP')
return
}
void act(
() => createSignOnExemption(props.cfgId, enabled.id),
`已为 ${enabled.name} 创建免 MFA 规则并置顶`,
)
}
</script>
<template>
<div class="flex flex-col gap-4">
<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">SAML 身份提供商</div>
<div class="flex items-center gap-2">
<NButton size="small" @click="doDownloadMetadata">下载 SP 元数据</NButton>
<NButton size="small" type="primary" @click="showCreate = true">创建 IdP</NButton>
</div>
</div>
<NDataTable
:columns="idpColumns"
:data="idps.data.value ?? []"
:loading="idps.loading.value"
:scroll-x="620"
:row-key="(r: IdentityProvider) => r.id"
/>
<FootNote>
推荐流程:下载 SP SAML 元数据导入 IdP 侧 → 创建 IdP → 激活(自动显示到登录页)→ 创建免 MFA
规则;停用与删除会先将 IdP 移出登录页
</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">Sign-on 策略(免 MFA 规则)</div>
<NButton size="small" @click="addExemption">为已启用 IdP 创建免 MFA 规则</NButton>
</div>
<NDataTable
:columns="ruleColumns"
:data="rules.data.value ?? []"
:loading="rules.loading.value"
:scroll-x="560"
:row-key="(r: SignOnRule) => r.id"
/>
<FootNote>
sign-on 策略管控全租户控制台登录;Oracle 预置规则不可删除,请勿手工改动预置规则顺序
</FootNote>
</div>
<IdpFormModal v-model:show="showCreate" :cfg-id="cfgId" @created="idps.run()" />
</div>
</template>
+409
View File
@@ -0,0 +1,409 @@
<script setup lang="ts">
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
ensureLogWebhook,
getLogRelay,
getLogWebhook,
revokeLogWebhook,
setupLogRelay,
teardownLogRelay,
} from '@/api/logevents'
import {
getIdentitySetting,
getNotificationRecipients,
listPasswordPolicies,
updateIdentitySetting,
updateNotificationRecipients,
} from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import PolicyEditModal from '@/components/tenant/PolicyEditModal.vue'
import { useAsync } from '@/composables/useAsync'
import type { PasswordPolicy } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const policies = useAsync(() => listPasswordPolicies(props.cfgId))
const recipients = useAsync(() => getNotificationRecipients(props.cfgId))
const identity = useAsync(() => getIdentitySetting(props.cfgId))
const webhook = useAsync(() => getLogWebhook(props.cfgId))
const relay = useAsync(() => getLogRelay(props.cfgId))
const savingIdentity = ref(false)
const savingWebhook = ref(false)
const settingUpRelay = ref(false)
const tearingRelay = ref(false)
// OCI 侧链路四资源,按数据流向排成管线;任一资源存在即视为「已建(或残留)」
const relayRows = computed(() => {
const d = relay.data.value
if (!d) return []
return [
{ label: 'Topic', res: d.topic },
{ label: '订阅 · HTTPS', res: d.subscription },
{ label: 'Policy', res: d.policy },
{ label: 'Connector', res: d.connector },
]
})
const relayBuilt = computed(() => relayRows.value.some((r) => r.res.id !== ''))
/** 主卡状态 chip:运行中 / 仅回调地址 / 未启用 */
const relayChip = computed(() => {
if (relay.data.value?.ready) return { label: '链路运行中', dot: 'bg-ok' }
if (webhook.data.value?.exists) return { label: '回调地址已生成', dot: 'bg-warn' }
return { label: '未启用', dot: 'bg-ink-3/50' }
})
function relayDotClass(res: { id: string; state: string }): string {
if (!res.id) return 'bg-ink-3/40'
if (res.state === 'ACTIVE' || !res.state) return 'bg-ok'
if (['PENDING', 'CREATING', 'UPDATING'].includes(res.state)) return 'bg-warn'
return 'bg-ink-3/40'
}
async function createRelay() {
settingUpRelay.value = true
try {
await setupLogRelay(props.cfgId)
message.success('回传链路已建立,关键事件将自动回传')
void relay.run()
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
void relay.run({ silent: true })
} finally {
settingUpRelay.value = false
}
}
async function destroyRelay() {
tearingRelay.value = true
try {
await teardownLogRelay(props.cfgId)
message.success('回传链路已销毁,回调地址已撤销')
void relay.run()
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '销毁失败')
void relay.run({ silent: true })
} finally {
tearingRelay.value = false
}
}
const emails = ref<string[]>([])
const saving = ref(false)
const showPolicyEdit = ref(false)
const editingPolicy = ref<PasswordPolicy | null>(null)
function openPolicyEdit(p: PasswordPolicy) {
editingPolicy.value = p
showPolicyEdit.value = true
}
watch(
() => recipients.data.value,
(data) => {
if (data) emails.value = [...data.recipients]
},
)
async function saveRecipients() {
saving.value = true
try {
await updateNotificationRecipients(props.cfgId, emails.value)
message.success(emails.value.length ? '通知收件人已保存' : '已关闭 test mode,恢复默认发送')
void recipients.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function toggleEmailRequired(v: boolean) {
savingIdentity.value = true
try {
await updateIdentitySetting(props.cfgId, v)
message.success(v ? '已开启:添加用户 / JIT 预配必须提供邮箱' : '已关闭:邮箱变为可选')
void identity.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingIdentity.value = false
}
}
async function createWebhook() {
savingWebhook.value = true
try {
await ensureLogWebhook(props.cfgId)
message.success('回调地址已生成')
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '生成失败')
} finally {
savingWebhook.value = false
}
}
async function removeWebhook() {
savingWebhook.value = true
try {
await revokeLogWebhook(props.cfgId)
message.success('回调地址已撤销,旧地址随即失效')
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '撤销失败')
} finally {
savingWebhook.value = false
}
}
async function copyWebhookPath() {
const path = webhook.data.value?.webhook?.path
if (!path) return
try {
await navigator.clipboard.writeText(path)
message.success('已复制回调路径,请以面板公网域名拼接完整 URL')
} catch {
message.error('复制失败,请手动选择文本复制')
}
}
function policyDesc(p: PasswordPolicy): string {
const parts = [`最短 ${p.minLength ?? 0}`]
parts.push(p.passwordExpiresAfter ? `${p.passwordExpiresAfter} 天过期` : '永不过期')
parts.push(
p.numPasswordsInHistory ? `不可复用最近 ${p.numPasswordsInHistory} 个密码` : '可复用以前的密码',
)
return `${p.passwordStrength} · ${parts.join(' · ')}`
}
</script>
<template>
<div class="flex flex-col gap-4">
<!-- 日志回传:全宽主卡,管线四节点 + 回调地址 + 事件清单 -->
<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">
OCI 审计关键事件推送到面板:Connector Hub Notifications 面板 webhook
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<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" :class="relayChip.dot" />
{{ relayChip.label }}
</span>
<NPopconfirm v-if="relayBuilt" @positive-click="destroyRelay">
<template #trigger>
<NButton size="tiny" type="error" quaternary :loading="tearingRelay">
销毁链路
</NButton>
</template>
将删除租户内的 ConnectorPolicy订阅与 Topic 并撤销回调地址,确认销毁?
</NPopconfirm>
<NButton
v-else
size="small"
type="primary"
:loading="settingUpRelay"
@click="createRelay"
>
一键创建链路
</NButton>
</div>
</div>
<div v-if="webhook.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<template v-else>
<div v-if="settingUpRelay" class="px-4.5 pt-3 text-xs text-warn">
正在创建 Topic 订阅(等待自动确认) Policy Connector,约需 1-2 分钟,请勿离开
</div>
<div v-else-if="relay.error.value" class="px-4.5 pt-3 text-xs text-warn">
{{ relay.error.value }}
</div>
<div
v-else-if="relayBuilt"
class="grid grid-cols-4 gap-2.5 px-4.5 pt-3.5 max-md:grid-cols-2"
>
<div
v-for="row in relayRows"
:key="row.label"
class="relative rounded-lg bg-wash px-3 py-2.5 after:absolute after:top-1/2 after:-right-[9px] after:-translate-y-1/2 after:text-xs after:text-ink-3 after:content-['→'] last:after:hidden max-md:after:hidden"
>
<div class="text-[11px] tracking-wider text-ink-3 uppercase">{{ row.label }}</div>
<div class="mt-1 flex items-center gap-1.5 text-[12.5px] font-semibold">
<span class="h-[7px] w-[7px] flex-none rounded-full" :class="relayDotClass(row.res)" />
{{ row.res.id ? row.res.state || '存在' : '未创建' }}
</div>
</div>
</div>
<div v-else class="px-4.5 pt-3.5 text-xs leading-relaxed text-ink-3">
在租户内自动创建 TopicCUSTOM_HTTPS 订阅IAM Policy Service Connector(_Audit
审计日志,含子区间),订阅确认由面板自动完成,全程约 1-2 分钟
</div>
<div
v-if="webhook.data.value?.exists"
class="mx-4.5 mt-3 flex items-center gap-2.5 rounded-md bg-wash px-3 py-2"
>
<span class="flex-none text-xs text-ink-3">回调地址</span>
<span class="mono min-w-0 flex-1 text-[12px] break-all text-ink-2">
{{ webhook.data.value.webhook?.path }}
</span>
<NButton size="tiny" quaternary class="flex-none" @click="copyWebhookPath">复制</NButton>
<NPopconfirm @positive-click="removeWebhook">
<template #trigger>
<NButton size="tiny" type="error" quaternary class="flex-none" :loading="savingWebhook">
撤销
</NButton>
</template>
撤销后旧回调地址立即 404,OCI 侧订阅将投递失败,确认撤销?
</NPopconfirm>
</div>
<div v-else class="mx-4.5 mt-3 flex flex-wrap items-center gap-2.5 rounded-md bg-wash px-3 py-2">
<span class="min-w-0 flex-1 text-xs text-ink-3">
推荐直接一键创建链路;仅需回调地址手动配置 OCI 侧时,可单独生成
</span>
<NButton size="tiny" :loading="savingWebhook" @click="createWebhook">
生成回调地址
</NButton>
</div>
<div class="px-4.5 pt-3.5 pb-4">
<div class="text-xs font-semibold text-ink-2">
回传事件清单(OCI Log Filter 收窄,仅以下关键事件回传,不产生噪声流量)
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="ev in relay.data.value?.events ?? []"
:key="ev"
class="mono rounded bg-wash px-1.5 py-0.5 text-[11px] text-ink-2"
>
{{ ev }}
</span>
</div>
</div>
</template>
<FootNote>
关键事件入库后另经通知管理 云端事件按类推送,可在设置中关闭;回调地址内含随机凭据,
请勿外传,复制路径后以面板公网域名拼接完整 URL
</FootNote>
</div>
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<!-- 密码策略与身份 -->
<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">Default 身份域 · IAM 规则</div>
</div>
<div v-if="policies.loading.value || identity.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else class="px-4.5 py-1.5">
<div
v-for="p in policies.data.value ?? []"
:key="p.id"
class="flex items-center gap-2.5 border-b border-line-soft py-2.5"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="mono text-[12.5px] text-ink">{{ p.name }}</span>
<StatusBadge
:kind="p.passwordStrength === 'Custom' ? 'snatch' : 'check'"
:label="p.passwordStrength === 'Custom' ? 'Custom' : '内置'"
:dot="false"
/>
</div>
<div class="mt-0.5 text-xs text-ink-3">
{{ policyDesc(p) }}
</div>
</div>
<NButton
v-if="p.passwordStrength === 'Custom'"
size="tiny"
quaternary
@click="openPolicyEdit(p)"
>
编辑
</NButton>
<span v-else class="text-[12.5px] text-ink-3">只读</span>
</div>
<div class="flex items-center gap-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium">用户需要提供主电子邮件地址</div>
<div class="mt-0.5 text-xs text-ink-3">
开启后添加用户必须填写邮箱,JIT 预配必须映射邮箱属性
</div>
</div>
<NSwitch
:value="identity.data.value?.primaryEmailRequired ?? false"
:loading="savingIdentity"
size="small"
@update:value="toggleEmailRequired"
/>
</div>
</div>
<FootNote>
Custom 强度的策略可修改,Simple / Standard Oracle
只读内置策略;关闭邮箱强制后找回密码与欢迎邮件不可用
</FootNote>
</div>
<!-- 通知收件人 -->
<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">
域通知 test mode:开启后全部通知只发给指定收件人
</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"
:class="recipients.data.value?.testModeEnabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
test mode {{ recipients.data.value?.testModeEnabled ? '已开启' : '未开启' }}
</span>
</div>
<div v-if="recipients.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else class="flex flex-col gap-3 px-4.5 py-3.5">
<NDynamicTags v-model:value="emails" />
<div class="flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="saveRecipients">
保存
</NButton>
<span class="text-xs text-ink-3">传空列表将关闭 test mode恢复域默认发送</span>
</div>
</div>
<FootNote>收件人为本租户级配置;适合把告警邮件集中到运维邮箱</FootNote>
</div>
</div>
<PolicyEditModal
v-model:show="showPolicyEdit"
:cfg-id="cfgId"
:policy="editingPolicy"
@updated="policies.run()"
/>
</div>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { reactive, ref, watch } from 'vue'
import { updatePasswordPolicy } from '@/api/tenant'
import FormModal from '@/components/FormModal.vue'
import type { PasswordPolicy } from '@/types/api'
const props = defineProps<{ show: boolean; cfgId: number; policy: PasswordPolicy | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
// 关闭开关时写回的控制台默认值
const DEFAULT_EXPIRES_DAYS = 120
const DEFAULT_HISTORY_COUNT = 4
const message = useMessage()
const submitting = ref(false)
const form = reactive({ neverExpires: true, allowReuse: true })
watch(
() => props.show,
(v) => {
if (!v || !props.policy) return
form.neverExpires = !props.policy.passwordExpiresAfter
form.allowReuse = !props.policy.numPasswordsInHistory
},
)
// 仅提交状态发生变化的开关,避免覆盖策略里已有的自定义天数 / 个数
function buildBody(p: PasswordPolicy) {
const body: { passwordExpiresAfter?: number; numPasswordsInHistory?: number } = {}
if (form.neverExpires !== !p.passwordExpiresAfter)
body.passwordExpiresAfter = form.neverExpires ? 0 : DEFAULT_EXPIRES_DAYS
if (form.allowReuse !== !p.numPasswordsInHistory)
body.numPasswordsInHistory = form.allowReuse ? 0 : DEFAULT_HISTORY_COUNT
return body
}
async function submit() {
if (!props.policy) return
const body = buildBody(props.policy)
if (!Object.keys(body).length) {
emit('update:show', false)
return
}
submitting.value = true
try {
await updatePasswordPolicy(props.cfgId, props.policy.id, body)
message.success('密码策略已更新')
emit('update:show', false)
emit('updated')
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败(仅 Custom 强度策略可修改)')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
:title="`编辑密码策略 · ${policy?.name ?? ''}`"
:submitting="submitting"
submit-text="保存"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between rounded border border-line-soft px-3.5 py-3">
<div class="min-w-0">
<div class="text-[13px] font-medium">密码永不过期</div>
<div class="mt-0.5 text-xs text-ink-3">
关闭后按 {{ policy?.passwordExpiresAfter || DEFAULT_EXPIRES_DAYS }} 天过期
</div>
</div>
<NSwitch v-model:value="form.neverExpires" />
</div>
<div class="flex items-center justify-between rounded border border-line-soft px-3.5 py-3">
<div class="min-w-0">
<div class="text-[13px] font-medium">可复用以前的密码</div>
<div class="mt-0.5 text-xs text-ink-3">
关闭后不可复用最近
{{ policy?.numPasswordsInHistory || DEFAULT_HISTORY_COUNT }} 个历史密码
</div>
</div>
<NSwitch v-model:value="form.allowReuse" />
</div>
</div>
<div class="mt-3 text-xs leading-relaxed text-ink-3">
Custom 强度的策略可修改Simple / Standard Oracle 只读内置策略
</div>
</FormModal>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import {
NCheckbox,
NDataTable,
NInput,
NInputGroup,
NInputGroupLabel,
NSelect,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, reactive, ref, watch } from 'vue'
import { listCachedRegions } from '@/api/configs'
import { listLimitServices, listLimits } from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import { regionCity, shortAd } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { LimitItem } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const service = ref('compute')
const name = ref('')
const region = ref('')
const scopeType = ref<'' | 'GLOBAL' | 'REGION' | 'AD'>('')
const withAvail = ref(false)
const hideZero = ref(false)
const scopeOptions = [
{ label: '全部作用域', value: '' },
{ label: 'GLOBAL', value: 'GLOBAL' },
{ label: 'REGION', value: 'REGION' },
{ label: 'AD', value: 'AD' },
]
const services = useAsync(() => listLimitServices(props.cfgId))
/** 区域选项来自服务端缓存(未开启多区域的租户只返回默认区域) */
const regions = useAsync(() => listCachedRegions(props.cfgId).catch(() => []))
const limits = useAsync(() =>
listLimits(props.cfgId, {
service: service.value,
name: name.value,
region: region.value || undefined,
scopeType: scopeType.value || undefined,
// 下推服务端过滤:0 配额不占「查用量」的 100 条并发名额
nonZero: hideZero.value || undefined,
withAvailability: withAvail.value,
}),
)
// 区域列表加载后默认选中主区域;已选值失效(退订)时同样回退
watch(
() => regions.data.value,
(subs) => {
if (!subs?.length) return
if (!region.value || !subs.some((s) => s.name === region.value))
region.value = subs.find((s) => s.isHomeRegion)?.name ?? subs[0].name
},
)
/** 选项用括号内城市短名(与全局选择器一致),避免选中框被长名截断 */
const regionOptions = computed(() =>
(regions.data.value ?? []).map((s) => ({
label: s.isHomeRegion ? `${regionCity(s.alias)} · 主` : regionCity(s.alias),
value: s.name,
})),
)
watch([service, withAvail, region, scopeType, hideZero], () => void limits.run())
const rows = computed(() =>
hideZero.value ? (limits.data.value ?? []).filter((r) => r.value !== 0) : (limits.data.value ?? []),
)
const serviceOptions = computed(
() =>
services.data.value?.map((s) => ({ label: s.description || s.name, value: s.name })) ?? [
{ label: 'Compute', value: 'compute' },
],
)
function usagePct(row: LimitItem): number | null {
if (row.used === undefined || row.value === 0) return null
return Math.round((row.used / row.value) * 100)
}
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
const pagination = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
onUpdatePage: (p: number) => {
pagination.page = p
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
},
})
const baseColumns: DataTableColumns<LimitItem> = [
{
title: '配额项',
key: 'name',
minWidth: 230,
render: (row) =>
h('div', [
h('div', { class: 'mono text-[12.5px] text-ink' }, row.name),
h(
'div',
{ class: 'text-xs text-ink-3 mt-px' },
row.availabilityDomain ? shortAd(row.availabilityDomain) : row.scopeType,
),
]),
},
{
title: '上限',
key: 'value',
width: 130,
render: (r) =>
h(
'span',
{ class: 'tabular-nums whitespace-nowrap' },
r.value.toLocaleString('en-US'),
),
},
]
const availColumns: DataTableColumns<LimitItem> = [
{
title: '已用',
key: 'used',
width: 80,
render: (r) => h('span', { class: 'tabular-nums' }, r.used === undefined ? '—' : String(r.used)),
},
{
title: '可用',
key: 'available',
width: 80,
render: (r) =>
h('span', { class: 'tabular-nums' }, r.available === undefined ? '—' : String(r.available)),
},
{
title: '用量',
key: 'pct',
minWidth: 160,
render: (row) => {
const pct = usagePct(row)
if (pct === null) return h('span', { class: 'text-xs text-ink-3' }, '无用量数据')
const full = pct >= 100
return h('div', { class: 'flex items-center gap-2' }, [
h('div', { class: 'h-1.5 w-24 overflow-hidden rounded-full bg-line-soft' }, [
h('div', {
class: 'h-full rounded-full',
style: { width: `${Math.min(pct, 100)}%`, background: full ? 'var(--color-err)' : 'var(--color-ok)' },
}),
]),
h(
'span',
{
class: 'text-[12.5px] tabular-nums',
style: full ? 'color:var(--color-err);font-weight:600' : 'color:var(--color-ink-2)',
},
`${pct}%`,
),
])
},
},
]
const columns = computed(() => (withAvail.value ? [...baseColumns, ...availColumns] : baseColumns))
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2 px-4.5 py-3">
<div class="text-sm font-semibold">服务配额</div>
<div class="flex-1" />
<NInputGroup class="!w-60 max-md:!w-full">
<NInputGroupLabel size="small">区域</NInputGroupLabel>
<NSelect
v-model:value="region"
size="small"
filterable
:options="regionOptions"
:loading="regions.loading.value"
:consistent-menu-width="false"
/>
</NInputGroup>
<NInputGroup class="!w-72 max-lg:!w-60 max-md:!w-full">
<NInputGroupLabel size="small">服务</NInputGroupLabel>
<NSelect
v-model:value="service"
size="small"
filterable
:options="serviceOptions"
:loading="services.loading.value"
/>
</NInputGroup>
<NInputGroup class="!w-52 max-lg:!w-44 max-md:!w-full">
<NInputGroupLabel size="small">作用域</NInputGroupLabel>
<NSelect v-model:value="scopeType" size="small" :options="scopeOptions" />
</NInputGroup>
<NInputGroup class="!w-56 max-lg:!w-44 max-md:!w-full">
<NInputGroupLabel size="small">配额名</NInputGroupLabel>
<NInput
v-model:value="name"
size="small"
placeholder="如 core-count"
clearable
@keyup.enter="limits.run()"
@clear="limits.run()"
/>
</NInputGroup>
<NCheckbox v-model:checked="hideZero" size="small">忽略 0 配额</NCheckbox>
<NCheckbox v-model:checked="withAvail" size="small">查用量</NCheckbox>
</div>
<NDataTable
:columns="columns"
:data="rows"
:loading="limits.loading.value"
:pagination="pagination"
:scroll-x="withAvail ? 660 : 380"
:max-height="440"
:row-key="(r: LimitItem) => r.name + (r.availabilityDomain ?? '')"
/>
<FootNote>
勾选查用量后单次限 100 超出请用配额名 / 作用域过滤收窄配合忽略 0
配额服务端过滤可大幅释放名额不支持用量查询的配额自动跳过并显示无用量数据
</FootNote>
</div>
</template>
+297
View File
@@ -0,0 +1,297 @@
<script setup lang="ts">
import { NButton, NDataTable, NSpin, type DataTableColumns } from 'naive-ui'
import { computed, h, ref } from 'vue'
import { listRegionSubscriptions, listRegions } from '@/api/configs'
import { getSubscription, listLimits, listSubscriptions } from '@/api/tenant'
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import SubscribeRegionModal from '@/components/tenant/SubscribeRegionModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useScopeStore } from '@/stores/scope'
import type { AccountType, Promotion, RegionInfo, SubscriptionDetail } from '@/types/api'
const props = defineProps<{ cfgId: number; accountType: AccountType }>()
const scope = useScopeStore()
/** 订阅成功后除本页列表外,同步刷新全局作用域的区域选项 */
function onSubscribed() {
void regionSubs.run()
void scope.refreshConfigs()
}
const showSubscribe = ref(false)
const presetKey = ref<string | null>(null)
/** 租户可能有多个订阅,逐个取详情;IDCS 身份域订阅无账单字段,不展示 */
const subs = useAsync(async () => {
const summaries = await listSubscriptions(props.cfgId)
const details = await Promise.all(summaries.map((s) => getSubscription(props.cfgId, s.id)))
return details.filter((d) => d.serviceName !== 'IDCS')
})
const regionSubs = useAsync(() => listRegionSubscriptions(props.cfgId))
const allRegions = useAsync(listRegions)
/** 租户可订阅区域上限(OCI Limitsregions / subscribed-region-countTenancy 级) */
const regionQuota = useAsync<number | null>(() =>
listLimits(props.cfgId, { service: 'regions', name: 'subscribed-region-count' })
.then((items) => items[0]?.value ?? null)
.catch(() => null),
)
const subscribedCount = computed(() => regionSubs.data.value?.length ?? 0)
const quotaFull = computed(() => {
const quota = regionQuota.data.value
return quota !== null && quota !== undefined && subscribedCount.value >= quota
})
interface RegionRow extends RegionInfo {
status: string
isHome: boolean
}
const regionRows = computed<RegionRow[]>(() => {
const byKey = new Map((regionSubs.data.value ?? []).map((s) => [s.key, s]))
const rows = (allRegions.data.value ?? []).map((r) => {
const sub = byKey.get(r.key)
return { ...r, status: sub?.status ?? '', isHome: sub?.isHomeRegion ?? false }
})
const rank = (r: RegionRow) => (r.isHome ? 0 : r.status ? 1 : 2)
return rows.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name))
})
function subscribe(row: RegionRow) {
presetKey.value = row.key
showSubscribe.value = true
}
function remainDays(expire: string | null): number | null {
if (!expire) return null
const days = Math.ceil((new Date(expire).getTime() - Date.now()) / 86400000)
return days > 0 ? days : 0
}
/** 促销到期:ACTIVE 促销 OCI 常不回填 timeExpired,用开始时间 + 时长推算 */
function promoExpire(r: Promotion): string | null {
if (r.timeExpired) return r.timeExpired
if (!r.timeStarted || !r.duration || !r.durationUnit.startsWith('DAY')) return null
return new Date(new Date(r.timeStarted).getTime() + r.duration * 86400000).toISOString()
}
const promoColumns: DataTableColumns<Promotion> = [
{
title: '时长',
key: 'duration',
width: 88,
render: (r) =>
h('span', { class: 'tabular-nums' }, `${r.duration} ${r.durationUnit.startsWith('DAY') ? '天' : r.durationUnit}`),
},
{
title: '额度',
key: 'amount',
width: 100,
render: (r) =>
h('span', { class: 'tabular-nums' }, `${r.currencyUnit === 'EUR' ? '€' : ''}${r.amount.toFixed(2)}`),
},
{
title: '状态',
key: 'status',
width: 96,
render: (r) => h(StatusBadge, { kind: r.status === 'ACTIVE' ? 'run' : 'stop', label: r.status }),
},
{
title: '开始',
key: 'timeStarted',
width: 108,
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeStarted).slice(0, 10)),
},
{
title: '到期',
key: 'timeExpired',
width: 108,
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(promoExpire(r)).slice(0, 10)),
},
{
title: '剩余',
key: 'remain',
width: 80,
render: (r) => {
const days = remainDays(promoExpire(r))
return days === null
? h('span', '—')
: h('span', { class: 'tabular-nums font-semibold', style: 'color:var(--color-warn)' }, `${days}`)
},
},
]
const regionColumns = computed<DataTableColumns<RegionRow>>(() => [
{
title: '区域',
key: 'name',
minWidth: 190,
render: (row) =>
h('div', [
h('div', { class: 'flex items-center gap-1.5' }, [
h('span', { class: 'text-[13px] font-medium' }, row.alias || row.name),
row.isHome
? h(
'span',
{ class: 'rounded bg-info/15 px-1 py-px text-[11px] font-medium text-info' },
'Home',
)
: null,
]),
h('div', { class: 'mono text-xs text-ink-3 mt-px' }, row.name),
]),
},
{ title: 'Key', key: 'key', width: 64, render: (r) => h('span', { class: 'mono' }, r.key) },
{
title: '状态',
key: 'status',
width: 100,
render: (r) =>
r.status
? h(StatusBadge, {
kind: r.status === 'READY' ? 'run' : 'prov',
label: r.status === 'READY' ? '已订阅' : '订阅中',
})
: h(StatusBadge, { kind: 'check', label: '未订阅', dot: false }),
},
{
title: '操作',
key: 'actions',
width: 76,
render: (row) =>
row.status
? h('span', { class: 'text-ink-3' }, '—')
: h(
NButton,
{
size: 'tiny',
quaternary: true,
type: 'primary',
disabled: quotaFull.value,
title: quotaFull.value
? `区域配额已满(${subscribedCount.value}/${regionQuota.data.value}),请先向 OCI 申请提额`
: undefined,
onClick: () => subscribe(row),
},
{ default: () => '订阅' },
),
},
])
function subLabel(d: SubscriptionDetail): string {
return d.serviceName || d.classicSubscriptionId || d.id
}
</script>
<template>
<div class="grid grid-cols-2 items-start gap-4 max-xl:grid-cols-1">
<div class="flex min-w-0 flex-col gap-4">
<div v-if="subs.loading.value" class="panel flex items-center justify-center py-14">
<NSpin size="small" />
</div>
<div
v-else-if="!subs.data.value?.length"
class="panel px-4.5 py-10 text-center text-[13px] text-ink-3"
>
未获取到订阅信息Organizations API 可能对该租户不可用
</div>
<div v-for="d in subs.data.value" v-else :key="d.id" 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">{{ subLabel(d) }} · 账户类别数据来源</div>
</div>
<div class="grid grid-cols-3 gap-4 px-4.5 py-3.5 max-md:grid-cols-2">
<div>
<div class="text-xs text-ink-3">付费模式</div>
<div class="mono mt-0.5 text-[13px]">{{ d.paymentModel || '—' }}</div>
</div>
<div>
<div class="text-xs text-ink-3">订阅层级</div>
<div class="mono mt-0.5 text-[13px]">{{ d.subscriptionTier || '—' }}</div>
</div>
<div>
<div class="text-xs text-ink-3">状态</div>
<div class="mt-0.5">
<StatusBadge
:kind="d.lifecycleState === 'ACTIVE' ? 'run' : 'stop'"
:label="d.lifecycleState || '—'"
/>
</div>
</div>
<div>
<div class="text-xs text-ink-3">账户判定</div>
<div class="mt-0.5"><AccountTypeBadge :type="accountType" /></div>
</div>
<div>
<div class="text-xs text-ink-3">币种</div>
<div class="mt-0.5 text-[13px]">{{ d.cloudAmountCurrency || '—' }}</div>
</div>
<div>
<div class="text-xs text-ink-3">Program</div>
<div class="mono mt-0.5 text-[13px]">{{ d.programType || '—' }}</div>
</div>
</div>
<template v-if="d.promotions.length">
<div class="flex items-center justify-between border-t border-line-soft px-4.5 py-2.5">
<div class="text-[13px] font-semibold">试用促销</div>
<div class="text-[12.5px] text-ink-3">{{ d.promotions.length }} </div>
</div>
<NDataTable
:columns="promoColumns"
:data="d.promotions"
:scroll-x="580"
:row-key="(r: Promotion) => r.timeStarted ?? ''"
/>
</template>
<FootNote>
判定规则:付费模式非 FREE_TRIAL → 升级;FREE_TRIAL 且促销 ACTIVE → 试用;促销 EXPIRED
或层级 FREE → 免费
</FootNote>
</div>
</div>
<div class="panel min-w-0">
<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">
已订阅 {{ subscribedCount }} ·
{{
regionQuota.loading.value
? '配额查询中…'
: regionQuota.data.value === null
? '配额未知'
: `配额 ${regionQuota.data.value}`
}}
</div>
</div>
<NDataTable
:columns="regionColumns"
:data="regionRows"
:loading="regionSubs.loading.value || allRegions.loading.value"
:scroll-x="460"
:max-height="480"
:row-key="(r: RegionRow) => r.key"
/>
<FootNote>
区域订阅一经创建不可取消,永久占用配额(regions / subscribed-region-count)· 新订阅生效需数分钟
</FootNote>
</div>
<SubscribeRegionModal
v-model:show="showSubscribe"
:cfg-id="cfgId"
:preset-key="presetKey"
:subscribed-keys="(regionSubs.data.value ?? []).map((r) => r.key)"
@subscribed="onSubscribed()"
/>
</div>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { NCheckbox, NSelect, useMessage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listRegions, subscribeRegion } from '@/api/configs'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{
show: boolean
cfgId: number
subscribedKeys: string[]
/** 从区域列表行内发起时预选并锁定该区域 */
presetKey?: string | null
}>()
const emit = defineEmits<{ 'update:show': [boolean]; subscribed: [] }>()
const message = useMessage()
const submitting = ref(false)
const regionKey = ref<string | null>(null)
const acknowledged = ref(false)
const regions = useAsync(listRegions, false)
watch(
() => props.show,
(v) => {
if (v) {
regionKey.value = props.presetKey ?? null
acknowledged.value = false
void regions.run()
}
},
)
const options = computed(
() =>
regions.data.value
?.filter((r) => !props.subscribedKeys.includes(r.key))
.map((r) => ({ label: `${r.alias}${r.name}`, value: r.key })) ?? [],
)
async function submit() {
if (!regionKey.value) return
submitting.value = true
try {
await subscribeRegion(props.cfgId, regionKey.value)
message.success('订阅请求已提交,生效需数分钟(状态 IN_PROGRESS')
emit('update:show', false)
emit('subscribed')
} catch (e) {
message.error(e instanceof Error ? e.message : '订阅失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="订阅新区域"
:submitting="submitting"
:submit-disabled="!regionKey || !acknowledged"
submit-text="确认订阅"
danger
@update:show="emit('update:show', $event)"
@submit="submit"
>
<div
class="mb-3.5 rounded-md border border-err/30 bg-err/10 px-3.5 py-2.5 text-[12.5px] leading-relaxed text-err"
>
区域订阅一经创建<strong>永久不可取消</strong>会永久占用该租户的区域配额并可能产生跨区域数据驻留影响请确认业务确实需要后再操作
</div>
<FormField label="目标区域" required :hint="presetKey ? undefined : '已订阅的区域不在列表中'">
<NSelect
v-model:value="regionKey"
filterable
:disabled="!!presetKey"
:options="options"
:loading="regions.loading.value"
placeholder="选择要订阅的区域"
/>
</FormField>
<NCheckbox v-model:checked="acknowledged">我已知晓区域订阅不可取消</NCheckbox>
</FormModal>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { NCheckbox, NInput, useMessage } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import {
createUser,
getIdentitySetting,
getUserDetail,
updateUser,
type UpdateUserBody,
} from '@/api/tenant'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import type { IamUser, IamUserDetail } from '@/types/api'
// user 非空即编辑模式:打开时拉取域档案预填充,提交只发送与原值不同的字段
const props = defineProps<{ show: boolean; cfgId: number; user?: IamUser | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const submitting = ref(false)
// 域开启「用户需要提供主电子邮件地址」时邮箱必填
const emailRequired = ref(false)
// 编辑模式的域档案;加载失败保持 null,降级为「留空不修改、勾选仅添加」
const detail = ref<IamUserDetail | null>(null)
const detailLoading = ref(false)
const blank = {
name: '',
givenName: '',
familyName: '',
email: '',
description: '',
grantDomainAdmin: false,
addToAdminGroup: false,
}
const form = reactive({ ...blank })
const editing = computed(() => !!props.user)
// 经典 IAM 用户不在身份域中,姓名与管理员状态均不可操作
const classicUser = computed(() => editing.value && !!detail.value && !detail.value.inDomain)
watch(
() => props.show,
(v) => {
if (!v) return
Object.assign(form, blank)
detail.value = null
if (props.user) {
form.name = props.user.name
form.email = props.user.email
form.description = props.user.description
void loadDetail(props.user.id)
}
void loadIdentitySetting()
},
)
async function loadIdentitySetting() {
try {
emailRequired.value = (await getIdentitySetting(props.cfgId)).primaryEmailRequired
} catch {
emailRequired.value = false
}
}
// 拉取域档案并回填表单;返回时弹窗已切到别的用户则丢弃结果
async function loadDetail(userId: string) {
detailLoading.value = true
try {
const d = await getUserDetail(props.cfgId, userId)
if (props.user?.id !== userId) return
detail.value = d
if (!d.inDomain) return
form.givenName = d.givenName
form.familyName = d.familyName
form.grantDomainAdmin = d.isDomainAdmin
form.addToAdminGroup = d.inAdminGroup
} catch {
if (props.user?.id === userId)
message.warning('用户详情加载失败:姓名留空表示不修改,管理员选项仅执行添加')
} finally {
if (props.user?.id === userId) detailLoading.value = false
}
}
const nameRequired = computed(() => !editing.value || !!detail.value?.inDomain)
const nameHint = computed(() => {
if (!editing.value || detailLoading.value) return undefined
if (classicUser.value) return '经典 IAM 用户不支持修改姓名'
if (!detail.value) return '详情加载失败:留空表示不修改,仅身份域用户可改姓名'
return undefined
})
const adminHint = computed(() => {
if (!editing.value)
return form.grantDomainAdmin && form.addToAdminGroup
? '同时勾选时先授予身份域管理员,再加入管理员组'
: undefined
if (detailLoading.value) return '正在加载当前管理员状态…'
if (classicUser.value) return '经典 IAM 用户不在身份域中,无法调整管理员角色与组'
if (!detail.value) return '详情加载失败:仅执行添加操作,不会撤销已有的管理员角色或组成员'
return '已按当前状态勾选;取消勾选保存后将撤销对应的管理员角色 / 组成员'
})
const canSubmit = computed(() => {
const emailOk = !emailRequired.value || !!form.email.trim()
if (!editing.value)
return !!form.name.trim() && !!form.givenName.trim() && !!form.familyName.trim() && emailOk
if (detailLoading.value) return false
if (detail.value?.inDomain && (!form.givenName.trim() || !form.familyName.trim())) return false
return emailOk
})
// 编辑只提交与原值不同的字段;域档案在手时管理员勾选与当前状态比对,取消勾选即撤销
function buildUpdateBody(u: IamUser): UpdateUserBody {
const body: UpdateUserBody = {}
if (form.email.trim() !== u.email) body.email = form.email.trim()
if (form.description.trim() !== u.description) body.description = form.description.trim()
if (classicUser.value) return body
const d = detail.value
if (d?.inDomain) {
if (form.givenName.trim() !== d.givenName) body.givenName = form.givenName.trim()
if (form.familyName.trim() !== d.familyName) body.familyName = form.familyName.trim()
if (form.grantDomainAdmin !== d.isDomainAdmin) body.grantDomainAdmin = form.grantDomainAdmin
if (form.addToAdminGroup !== d.inAdminGroup) body.addToAdminGroup = form.addToAdminGroup
return body
}
if (form.givenName.trim()) body.givenName = form.givenName.trim()
if (form.familyName.trim()) body.familyName = form.familyName.trim()
if (form.grantDomainAdmin) body.grantDomainAdmin = true
if (form.addToAdminGroup) body.addToAdminGroup = true
return body
}
async function submitEdit(u: IamUser) {
const body = buildUpdateBody(u)
if (!Object.keys(body).length) {
emit('update:show', false)
return
}
await updateUser(props.cfgId, u.id, body)
message.success(`用户「${u.name}」已更新`)
}
async function submitCreate() {
const created = await createUser(props.cfgId, {
name: form.name.trim(),
givenName: form.givenName.trim(),
familyName: form.familyName.trim(),
email: form.email.trim() || undefined,
description: form.description.trim() || undefined,
grantDomainAdmin: form.grantDomainAdmin,
addToAdminGroup: form.addToAdminGroup,
})
message.success(`用户「${created.name}」已创建,可再执行「重置密码」下发初始密码`)
}
async function submit() {
if (!canSubmit.value) return
submitting.value = true
try {
if (props.user) await submitEdit(props.user)
else await submitCreate()
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : editing.value ? '更新失败' : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
:title="editing ? `修改用户 · ${user?.name ?? ''}` : '添加用户'"
:submitting="submitting"
:submit-disabled="!canSubmit"
:submit-text="editing ? '保存' : '创建'"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField v-if="!editing" label="用户名" required>
<NInput v-model:value="form.name" placeholder="new-user" @keyup.enter="submit" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="名字" :required="nameRequired" :hint="nameHint">
<NInput
v-model:value="form.givenName"
placeholder="First name"
:disabled="classicUser"
:loading="detailLoading"
/>
</FormField>
<FormField label="姓氏" :required="nameRequired">
<NInput
v-model:value="form.familyName"
placeholder="Last name"
:disabled="classicUser"
:loading="detailLoading"
/>
</FormField>
</div>
<FormField
label="邮箱"
:required="emailRequired"
:hint="
emailRequired
? '域已开启「用户需要提供主电子邮件地址」,必须填写'
: '用于接收欢迎邮件与找回密码'
"
>
<NInput v-model:value="form.email" placeholder="user@example.com" />
</FormField>
<FormField label="备注" :hint="editing ? undefined : '留空默认取「名字 姓氏」'">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
<div class="mb-2 flex flex-col gap-1.5">
<NCheckbox
v-model:checked="form.grantDomainAdmin"
:disabled="classicUser || detailLoading"
>
{{ editing ? '身份域管理员' : '添加到身份域管理员' }}
</NCheckbox>
<NCheckbox v-model:checked="form.addToAdminGroup" :disabled="classicUser || detailLoading">
{{ editing ? '管理员组(Administrators)成员' : '添加到管理员组(Administrators' }}
</NCheckbox>
<div v-if="adminHint" class="text-xs text-ink-3">{{ adminHint }}</div>
</div>
</FormModal>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import { clearUserMfa, deleteUser, deleteUserApiKeys, listUsers, resetUserPassword } from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { IamUser } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const dialog = useDialog()
const users = useAsync(() => listUsers(props.cfgId))
const showForm = ref(false)
const editingUser = ref<IamUser | null>(null)
function openCreate() {
editingUser.value = null
showForm.value = true
}
function openEdit(row: IamUser) {
editingUser.value = row
showForm.value = true
}
async function act(fn: () => Promise<unknown>, ok: string) {
try {
await fn()
message.success(ok)
void users.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
function confirmDelete(row: IamUser) {
dialog.warning({
title: '删除用户',
content: () =>
h('div', { class: 'text-[13px]' }, [
h('p', `确定删除用户「${row.name}」?此操作不可恢复。`),
h('p', { class: 'mt-1.5 text-ink-3' }, '删除后该用户的全部 API Key 将一并吊销'),
]),
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () => act(() => deleteUser(props.cfgId, row.id), `已删除用户 ${row.name}`),
})
}
async function resetPwd(row: IamUser) {
try {
const { password } = await resetUserPassword(props.cfgId, row.id)
dialog.success({
title: '一次性密码',
content: () =>
h('div', [
h('p', { class: 'mb-2 text-[13px]' }, `${row.name} 的新密码(首次登录须修改):`),
h('code', { class: 'mono rounded bg-line-soft px-2 py-1 select-all' }, password),
]),
positiveText: '已保存',
})
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
}
}
const columns: DataTableColumns<IamUser> = [
{
title: '用户',
key: 'name',
minWidth: 220,
render: (row) =>
h('div', [
h('div', { class: 'flex items-center gap-2' }, [
h('span', { class: 'font-medium' }, row.name),
row.isCurrentUser
? h(StatusBadge, { kind: 'snatch', label: '当前签名用户', dot: false })
: null,
]),
h('div', { class: 'text-xs text-ink-3 mt-px' }, row.email || row.description || '—'),
]),
},
{
title: '状态',
key: 'lifecycleState',
width: 90,
render: (row) =>
h(StatusBadge, {
kind: row.lifecycleState === 'ACTIVE' ? 'run' : 'stop',
label: row.lifecycleState === 'ACTIVE' ? '活跃' : row.lifecycleState,
}),
},
{
title: 'MFA',
key: 'mfaActivated',
width: 70,
render: (row) => h('span', { class: 'text-[13px] tabular-nums' }, row.mfaActivated ? '已启用' : '未启用'),
},
{
title: '创建时间',
key: 'timeCreated',
width: 150,
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(row.timeCreated)),
},
{
title: '操作',
key: 'actions',
width: 290,
render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => resetPwd(row) }, { default: () => '重置密码' }),
h(
NPopconfirm,
{
onPositiveClick: () =>
act(() => clearUserMfa(props.cfgId, row.id), `已清除 ${row.name} 的全部 MFA`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
default: () => '清除该用户全部 MFA 因子?',
},
),
h(
NPopconfirm,
{
onPositiveClick: () =>
act(() => deleteUserApiKeys(props.cfgId, row.id), `已删除 ${row.name} 的 API Key`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删 Key' }),
default: () => '删除该用户全部 API Key(跳过当前配置使用中的指纹)?',
},
),
row.isCurrentUser
? null
: h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) },
{ default: () => '删除' },
),
]),
},
]
</script>
<template>
<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">IAM 用户</div>
<NButton size="small" type="primary" @click="openCreate">添加用户</NButton>
</div>
<NDataTable
:columns="columns"
:data="users.data.value ?? []"
:loading="users.loading.value"
:scroll-x="800"
:row-key="(r: IamUser) => r.id"
/>
<FootNote>
重置密码生成一次性密码,用户首次登录须修改;当前配置签名用户不可删除,避免面板失联
</FootNote>
<UserFormModal
v-model:show="showForm"
:cfg-id="cfgId"
:user="editingUser"
@created="users.run()"
/>
</div>
</template>