初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 断言名称 ID(NameID)', 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
将删除租户内的 Connector、Policy、订阅与 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">
|
||||
在租户内自动创建 Topic、CUSTOM_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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 Limits:regions / subscribed-region-count,Tenancy 级) */
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user