新增对象存储/账单/保留IP页面,成本今天视图与样式收敛
CI / test (push) Successful in 42s

This commit is contained in:
2026-07-21 12:11:22 +08:00
parent d813225ac4
commit ac457282ce
91 changed files with 6672 additions and 652 deletions
+3 -7
View File
@@ -5,6 +5,7 @@ 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 EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
@@ -267,7 +268,7 @@ const columns: DataTableColumns<AuditEvent> = [
<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="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>
<StatusBadge v-if="exhausted" kind="run" label="已加载全部保留期数据" :dot="false" />
<div class="flex-1" />
@@ -285,12 +286,7 @@ const columns: DataTableColumns<AuditEvent> = [
<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>
<EmptyCard v-if="error" title="审计日志加载失败" :note="error" />
<NDataTable
v-else
:columns="columns"
+141 -41
View File
@@ -2,49 +2,122 @@
import { NSelect, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { getCosts } from '@/api/analytics'
import { getCosts, type CostQuery } 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)
/** 'today' 走小时粒度(本地零点起);数字为最近 N 天(天粒度) */
const range = ref<'today' | 14 | 30>(14)
const rangeOptions = [
{ label: '今天', value: 'today' as const },
{ 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)
/** 一次复合分组查询(service,skuName):总额/曲线/服务/产品全部本地聚合,不加请求 */
function buildQuery(): CostQuery {
const q: CostQuery = { groupBy: 'service,skuName' }
if (range.value === 'today') {
const midnight = new Date()
midnight.setHours(0, 0, 0, 0)
q.granularity = 'HOURLY'
q.startTime = midnight.toISOString()
} else {
q.granularity = 'DAILY'
q.startTime = new Date(Date.now() - range.value * 86400e3).toISOString()
}
const labels = [...byDay.keys()].sort()
return { labels, values: labels.map((l) => +(byDay.get(l) ?? 0).toFixed(2)) }
return q
}
const costs = useAsync(() => getCosts(props.cfgId, buildQuery()))
watch(range, () => void costs.run())
/** 时间桶标签:今天按本地小时(HH:00),天粒度按 MM-DD(UTC 日) */
function bucketLabel(timeStart: string): string {
if (range.value !== 'today') return timeStart.slice(5, 10)
return `${String(new Date(timeStart).getHours()).padStart(2, '0')}:00`
}
/** 完整时间轴:Usage API 对无出账记录的时段不返回行,不补零桶会断轴缺刻度 */
function fullLabels(): string[] {
if (range.value === 'today') {
const hours = new Date().getHours()
return Array.from({ length: hours + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`)
}
const days = range.value
return Array.from({ length: days + 1 }, (_, i) =>
new Date(Date.now() - (days - i) * 86400e3).toISOString().slice(5, 10),
)
}
const series = computed(() => {
const byBucket = new Map<string, number>()
for (const item of costs.data.value ?? []) {
const key = bucketLabel(item.timeStart)
byBucket.set(key, (byBucket.get(key) ?? 0) + item.computedAmount)
}
const labels = fullLabels()
return { labels, values: labels.map((l) => +(byBucket.get(l) ?? 0).toFixed(2)) }
})
const total = computed(() => daily.value.values.reduce((s, v) => s + v, 0))
const total = computed(() => series.value.values.reduce((s, v) => s + v, 0))
const byService = computed(() => {
const map = new Map<string, number>()
interface ProductRow {
name: string
amount: number
pct: number
}
interface ServiceRow {
name: string
amount: number
pct: number
barWidth: string
products: ProductRow[]
}
/** 服务榜单 + 各服务内产品(SKU)明细,均按金额倒序 */
const byService = computed<ServiceRow[]>(() => {
const svcTotal = new Map<string, number>()
const skuTotal = new Map<string, Map<string, number>>()
for (const item of costs.data.value ?? []) {
map.set(item.groupValue, (map.get(item.groupValue) ?? 0) + item.computedAmount)
const svc = item.groupValue || '(未知服务)'
svcTotal.set(svc, (svcTotal.get(svc) ?? 0) + item.computedAmount)
const skus = skuTotal.get(svc) ?? new Map<string, number>()
const sku = item.subValue || '(未细分)'
skus.set(sku, (skus.get(sku) ?? 0) + item.computedAmount)
skuTotal.set(svc, skus)
}
const rows = [...map.entries()].sort((a, b) => b[1] - a[1])
const rows = [...svcTotal.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)}%`,
products: [...(skuTotal.get(name) ?? new Map<string, number>()).entries()]
.sort((a, b) => b[1] - a[1])
.map(([sku, amt]) => ({
name: sku,
amount: amt,
pct: amount ? Math.round((amt / amount) * 100) : 0,
})),
}))
})
// ---- 展开态:按服务名记录,切范围后清空 ----
const expanded = ref(new Set<string>())
watch(range, () => (expanded.value = new Set()))
function toggle(name: string) {
const next = new Set(expanded.value)
if (next.has(name)) next.delete(name)
else next.add(name)
expanded.value = next
}
</script>
<template>
@@ -53,7 +126,7 @@ const byService = computed(() => {
<div class="text-sm font-semibold">成本分析</div>
<div class="flex-1" />
<div class="w-28">
<NSelect v-model:value="rangeDays" size="small" :options="rangeOptions" />
<NSelect v-model:value="range" size="small" :options="rangeOptions" />
</div>
</div>
@@ -64,43 +137,70 @@ const byService = computed(() => {
<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) }} 合计
{{
range === 'today'
? '今天 0 点至今合计(小时粒度)'
: `${series.labels[0]}${series.labels.at(-1)} 合计`
}}
</div>
</div>
<div class="px-3 pb-1">
<CostChart :labels="daily.labels" :values="daily.values" />
<CostChart :labels="series.labels" :values="series.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 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 }}
<template v-for="svc in byService" :key="svc.name">
<div
class="flex cursor-pointer items-center gap-3 rounded py-1.5 select-none hover:bg-wash/70 max-md:gap-2"
@click="toggle(svc.name)"
>
<span
class="w-3 flex-none text-center text-[10px] text-ink-3 transition-transform"
:class="expanded.has(svc.name) ? 'rotate-90' : ''"
>
</span>
<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 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 v-if="expanded.has(svc.name)" class="mb-1 ml-3 border-l border-line-soft pl-4">
<div
v-for="p in svc.products"
:key="p.name"
class="flex items-center gap-3 py-1 max-md:gap-2"
>
<div class="min-w-0 flex-1 truncate text-[12.5px] text-ink-2" :title="p.name">
{{ p.name }}
</div>
<div class="w-17 flex-none text-right text-[12.5px] tabular-nums">
${{ p.amount.toFixed(2) }}
</div>
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">
{{ p.pct }}%
</div>
</div>
</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>
</template>
</div>
</template>
<EmptyCard
v-else
title="暂无成本数据"
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天"
:title="range === 'today' ? '今天暂无成本数据' : '暂无成本数据'"
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天,「今天」视图可能滞后"
/>
<FootNote>
每日 03:30 自动同步 Usage API · 本地快照 · 免费类别租户不参与成本同步 · 点击刷新可即时拉取
</FootNote>
</div>
</template>
+7 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, NSwitch, useDialog, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NSwitch, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import {
@@ -16,6 +16,7 @@ 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'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number; domainId?: string }>()
@@ -121,15 +122,17 @@ const ruleColumns: DataTableColumns<SignOnRule> = [
row.builtIn
? h('span', { class: 'text-xs text-ink-3' }, '内置')
: h(
NPopconfirm,
ConfirmPop,
{
onPositiveClick: () =>
title: '删除该免 MFA 规则?',
content: '删除后经此 IdP 登录将重新要求 MFA。',
confirmText: '删除',
onConfirm: () =>
act(() => deleteSignOnExemption(props.cfgId, row.id, props.domainId), '已删除免 MFA 规则并恢复优先级'),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
default: () => '删除该免 MFA 规则?删除后经此 IdP 登录将重新要求 MFA',
},
),
},
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { NButton, NModal, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { fetchInvoicePdf, listInvoiceLines, saveInvoicePdf } from '@/api/billing'
import StatusBadge from '@/components/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
import { invoiceDate, invoiceStatusMeta } from '@/components/tenant/invoiceStatus'
import { useAsync } from '@/composables/useAsync'
import { fmtMoney } from '@/composables/useFormat'
import type { Invoice } from '@/types/api'
/** 发票明细弹窗:费用行 + 合计 + 付款/PDF 入口 */
const props = defineProps<{ show: boolean; cfgId: number; invoice: Invoice | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; pay: [Invoice] }>()
const message = useToast()
const lines = useAsync(async () => {
const inv = props.invoice
return inv ? listInvoiceLines(props.cfgId, inv.internalId) : []
}, false)
watch(
() => props.show,
(v) => {
if (v && props.invoice) void lines.run()
},
)
const meta = computed(() => (props.invoice ? invoiceStatusMeta(props.invoice) : null))
const total = computed(() => (lines.data.value ?? []).reduce((s, l) => s + l.total, 0))
const payable = computed(() => !!props.invoice && props.invoice.isPayable && !props.invoice.isPaid)
const downloading = ref(false)
async function onPdf() {
const inv = props.invoice
if (!inv) return
downloading.value = true
try {
saveInvoicePdf(await fetchInvoicePdf(props.cfgId, inv.internalId), inv.number || inv.internalId)
} catch (e) {
message.error(e instanceof Error ? e.message : 'PDF 下载失败')
} finally {
downloading.value = false
}
}
</script>
<template>
<NModal
:show="show"
preset="card"
:title="invoice ? `发票 ${invoice.number || invoice.id}` : '发票明细'"
:style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<template v-if="invoice">
<div class="rounded-md bg-wash px-4 py-3">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[24px] font-semibold tabular-nums">
{{ fmtMoney(invoice.amount, invoice.currency) }}
</div>
<StatusBadge v-if="meta" :kind="meta.kind" :label="meta.label" />
</div>
<div class="mt-1.5 text-xs text-ink-3">
{{ invoice.type || 'USAGE' }} · 开票 {{ invoiceDate(invoice.timeInvoice) }} · 到期
{{ invoiceDate(invoice.timeDue) }}
</div>
<div v-if="invoice.isPaymentFailed" class="mt-1.5 text-xs text-err">
上次自动扣款失败,请检查付款方式后手动支付
</div>
</div>
<div class="mt-4 mb-1.5 text-[13px] font-semibold">费用明细</div>
<div class="max-h-[46vh] overflow-y-auto">
<div v-if="lines.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else-if="lines.error.value" class="py-6 text-center text-xs text-err">
{{ lines.error.value }}
</div>
<template v-else>
<div
v-for="(l, i) in lines.data.value ?? []"
:key="i"
class="flex items-center gap-3 border-b border-line-soft py-2 last:border-b-0"
>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px]" :title="l.product">{{ l.product }}</div>
<div class="mt-0.5 text-xs text-ink-3">
{{ invoiceDate(l.timeStart) }} ~ {{ invoiceDate(l.timeEnd) }}
</div>
</div>
<div class="flex-none text-right text-xs text-ink-3 tabular-nums">
{{ l.quantity.toLocaleString() }} × {{ l.unitPrice }}
</div>
<div class="w-20 flex-none text-right text-[13px] tabular-nums">
{{ fmtMoney(l.total, l.currency || invoice.currency) }}
</div>
</div>
<div v-if="!lines.data.value?.length" class="py-6 text-center text-xs text-ink-3">
该发票暂无费用行
</div>
</template>
</div>
<div
v-if="lines.data.value?.length"
class="flex items-center justify-between border-t border-line pt-2.5 text-[13px] font-semibold"
>
<span>合计</span>
<span class="tabular-nums">{{ fmtMoney(total, invoice.currency) }}</span>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2.5">
<NButton size="small" :loading="downloading" @click="onPdf">下载 PDF</NButton>
<NButton
v-if="payable && invoice"
size="small"
type="primary"
@click="emit('pay', invoice)"
>
立即付款
</NButton>
</div>
</template>
</NModal>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { NInput } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { payInvoice } from '@/api/billing'
import FormModal from '@/components/FormModal.vue'
import { useToast } from '@/composables/useToast'
import { invoiceDate } from '@/components/tenant/invoiceStatus'
import { fmtMoney } from '@/composables/useFormat'
import type { Invoice, PaymentMethod } from '@/types/api'
/** 付款确认:经 OSP Gateway PayInvoice 按订阅默认付款方式即时扣款;
* email 是 API 必填项(接收付款回执),在此向用户收集。 */
const props = defineProps<{
show: boolean
cfgId: number
invoice: Invoice | null
methods: PaymentMethod[] | null
}>()
const emit = defineEmits<{ 'update:show': [boolean]; paid: [] }>()
const message = useToast()
const email = ref('')
const paying = ref(false)
const emailOk = computed(() => /.+@.+\..+/.test(email.value.trim()))
/** 扣款方式展示:优先第一张信用卡,其次任一登记方式 */
const payVia = computed(() => {
const ms = props.methods ?? []
const m = ms.find((x) => x.method === 'CREDIT_CARD') ?? ms[0]
if (!m) return '订阅默认付款方式'
if (m.method === 'CREDIT_CARD') return `${m.cardType || '银行卡'} •••• ${m.lastDigits}`
return `PayPal(${m.email})`
})
watch(
() => props.show,
(v) => {
if (v) paying.value = false
},
)
async function doPay() {
const inv = props.invoice
if (!inv || !emailOk.value) return
paying.value = true
try {
await payInvoice(props.cfgId, inv.internalId, email.value.trim())
message.success('付款已提交,发票状态稍后更新')
emit('update:show', false)
emit('paid')
} catch (e) {
message.error(e instanceof Error ? e.message : '付款失败')
} finally {
paying.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="支付发票"
:width="440"
submit-text="确认支付"
:submitting="paying"
:submit-disabled="!emailOk"
@update:show="emit('update:show', $event)"
@submit="doPay"
>
<template v-if="invoice">
<div class="rounded-md bg-wash px-4 py-3">
<div class="text-xs text-ink-3">应付金额</div>
<div class="mt-1 text-[24px] font-semibold tabular-nums">
{{ fmtMoney(invoice.amountDue || invoice.amount, invoice.currency) }}
</div>
<div class="mt-1 text-xs text-ink-3">
发票 {{ invoice.number }} · 到期 {{ invoiceDate(invoice.timeDue) }}
</div>
</div>
<div
class="mt-3 flex items-center justify-between rounded-md border border-line-soft px-4 py-2.5 text-[13px]"
>
<span class="text-ink-3">扣款方式</span>
<span class="font-medium">{{ payVia }}</span>
</div>
<div class="mt-3">
<div class="mb-1 text-xs text-ink-3">回执邮箱(必填)</div>
<NInput v-model:value="email" placeholder="billing@example.com" />
</div>
<div class="mt-3 text-xs leading-relaxed text-ink-3">
确认后经 OCI 支付网关按上述方式即时扣款,回执发送至邮箱;提交后发票转入处理中,
入账通常需要数分钟,期间请勿重复支付
</div>
</template>
</FormModal>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { NButton, NModal, NSpin } from 'naive-ui'
import { ref, watch } from 'vue'
import { fetchInvoicePdf, saveInvoicePdf } from '@/api/billing'
import OfficePreview from '@/components/objectstorage/viewer/OfficePreview.vue'
import type { Invoice } from '@/types/api'
/** 发票 PDF 预览弹窗:面板代理拉取后 vue-office 渲染,预览框内可下载 */
const props = defineProps<{ show: boolean; cfgId: number; invoice: Invoice | null }>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
const loading = ref(false)
const loadError = ref('')
const pdfData = ref<ArrayBuffer | null>(null)
let blob: Blob | null = null
watch(
() => props.show,
(v) => {
if (v) void load()
},
)
async function load() {
const inv = props.invoice
if (!inv) return
loading.value = true
loadError.value = ''
pdfData.value = null
try {
blob = await fetchInvoicePdf(props.cfgId, inv.internalId)
pdfData.value = await blob.arrayBuffer()
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'PDF 加载失败'
} finally {
loading.value = false
}
}
function download() {
const inv = props.invoice
if (blob && inv) saveInvoicePdf(blob, inv.number || inv.internalId)
}
</script>
<template>
<NModal
:show="show"
preset="card"
:title="invoice ? `发票 ${invoice.number || invoice.id} · PDF` : 'PDF 预览'"
:style="{ width: '860px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<div class="h-[70vh] overflow-auto rounded-lg border border-line-soft bg-wash/50">
<div v-if="loading" class="flex h-full items-center justify-center">
<NSpin size="small" />
</div>
<div
v-else-if="loadError"
class="flex h-full flex-col items-center justify-center gap-2 text-xs text-err"
>
{{ loadError }}
<NButton size="small" @click="load">重试</NButton>
</div>
<div v-else-if="pdfData" class="min-h-full bg-white">
<OfficePreview kind="pdf" :data="pdfData" @error="loadError = $event" />
</div>
</div>
<template #footer>
<div class="flex justify-end">
<NButton size="small" type="primary" :disabled="!pdfData" @click="download">下载</NButton>
</div>
</template>
</NModal>
</template>
+348
View File
@@ -0,0 +1,348 @@
<script setup lang="ts">
import { NButton, NDataTable, NSpin, type DataTableColumns } from 'naive-ui'
import { computed, h, ref } from 'vue'
import { listInvoices, listPaymentMethods } from '@/api/billing'
import { ApiError } from '@/api/request'
import EmptyCard from '@/components/EmptyCard.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import InvoiceDetailModal from '@/components/tenant/InvoiceDetailModal.vue'
import InvoicePayModal from '@/components/tenant/InvoicePayModal.vue'
import InvoicePdfModal from '@/components/tenant/InvoicePdfModal.vue'
import PaymentMethodsModal from '@/components/tenant/PaymentMethodsModal.vue'
import { invoiceDate, invoiceStatusMeta } from '@/components/tenant/invoiceStatus'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
import { fmtMoney } from '@/composables/useFormat'
import type { Invoice } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useToast()
// ---- 按年懒加载:初始拉当年(空则自动往前探),底部按钮逐年加载,连空 2 年停 ----
const thisYear = new Date().getFullYear()
const EMPTY_STOP = 2
const rows = ref<Invoice[]>([])
/** 下一个待加载的自然年 */
const nextYear = ref(thisYear)
const exhausted = ref(false)
const initialLoading = ref(true)
const loadingMore = ref(false)
/** 无 osp-gateway 权限时整 tab 转受限卡,不当作普通错误 */
const forbidden = ref(false)
/** 组织被 OCI 账单平台排除(巴西等本地结算体系注册地),账单能力整体不适用 */
const excluded = ref(false)
const loadError = ref('')
let emptyStreak = 0
/** 拉一个自然年并入列表 */
async function loadYear(year: number) {
const batch = await listInvoices(props.cfgId, year)
nextYear.value = year - 1
if (batch.length) {
emptyStreak = 0
rows.value = [...rows.value, ...batch]
} else if (++emptyStreak >= EMPTY_STOP) {
exhausted.value = true
}
}
async function init() {
initialLoading.value = true
forbidden.value = false
excluded.value = false
loadError.value = ''
rows.value = []
nextYear.value = thisYear
exhausted.value = false
emptyStreak = 0
try {
// 自动往前探,直到有数据或连空到停
while (!exhausted.value && !rows.value.length) await loadYear(nextYear.value)
} catch (e) {
// ociCode 判定先于 403:排除类错误可能也以 403 状态返回,不能被权限分支抢走
if (e instanceof ApiError && e.ociCode === 'OrganizationExclusionError') excluded.value = true
else if (e instanceof ApiError && e.status === 403) forbidden.value = true
else loadError.value = e instanceof Error ? e.message : String(e)
} finally {
initialLoading.value = false
}
}
void init()
async function loadMore() {
loadingMore.value = true
try {
await loadYear(nextYear.value)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
loadingMore.value = false
}
}
/** 付款后静默重拉已加载的年份区间,不闪加载态 */
async function refreshSilent() {
const years: number[] = []
for (let y = thisYear; y > nextYear.value; y--) years.push(y)
try {
const batches = await Promise.all(years.map((y) => listInvoices(props.cfgId, y)))
rows.value = batches.flat()
} catch {
/* 静默失败,保留现有数据 */
}
}
/** 非 PAYG 计划(如年度合约/内部账号)没有自助付款方式,单独提示而非报错 */
const methodsUnsupported = ref(false)
/** 付款方式受同一 policy 管制,其余失败静默置空(受限卡已说明原因) */
const methods = useAsync(() =>
listPaymentMethods(props.cfgId).catch((e) => {
if (e instanceof ApiError && e.ociCode === 'InvalidPlanType') methodsUnsupported.value = true
return []
}),
)
// ---- 汇总 ----
/** API 返回序不保证,统一按开票时间倒序 */
const list = computed(() =>
[...rows.value].sort((a, b) => (b.timeInvoice ?? '').localeCompare(a.timeInvoice ?? '')),
)
const currency = computed(() => list.value[0]?.currency || 'USD')
/** 未付余额只计还可操作的欠款;「处理中」已提交扣款,不再计入 */
const unpaidDue = computed(() =>
list.value
.filter((i) => !i.isPaid && (i.status === 'OPEN' || i.status === 'PAST_DUE'))
.reduce((s, i) => s + i.amountDue, 0),
)
const yearTotal = computed(() =>
list.value
.filter((i) => (i.timeInvoice ?? '').startsWith(String(thisYear)))
.reduce((s, i) => s + i.amount, 0),
)
const lastPaid = computed(() => list.value.find((i) => i.isPaid) ?? null)
const methodBrief = computed(() => {
if (methodsUnsupported.value) return '不适用(非 PAYG'
const m = (methods.data.value ?? [])[0]
if (!m) return '未登记'
return m.method === 'CREDIT_CARD' ? `${m.cardType || '银行卡'} •••• ${m.lastDigits}` : 'PayPal'
})
// ---- 弹窗 ----
const detailInv = ref<Invoice | null>(null)
const showDetail = ref(false)
const payInv = ref<Invoice | null>(null)
const showPay = ref(false)
const pdfInv = ref<Invoice | null>(null)
const showPdf = ref(false)
const showMethods = ref(false)
function openDetail(inv: Invoice) {
detailInv.value = inv
showDetail.value = true
}
function openPay(inv: Invoice) {
payInv.value = inv
showPay.value = true
}
function openPdf(inv: Invoice) {
pdfInv.value = inv
showPdf.value = true
}
/** 明细弹窗里点付款:先关明细再开付款确认 */
function payFromDetail(inv: Invoice) {
showDetail.value = false
openPay(inv)
}
const columns = computed<DataTableColumns<Invoice>>(() => [
{
title: '发票号',
key: 'number',
render: (r) =>
h('div', [
h('div', { class: 'text-[13px] font-medium tabular-nums' }, r.number || r.id),
h('div', { class: 'mt-0.5 text-xs text-ink-3' }, r.type || 'USAGE'),
]),
},
{ title: '开票日期', key: 'timeInvoice', width: 110, render: (r) => invoiceDate(r.timeInvoice) },
{
title: '到期日期',
key: 'timeDue',
width: 110,
render: (r) =>
h('span', { class: r.status === 'PAST_DUE' ? 'text-err' : '' }, invoiceDate(r.timeDue)),
},
{
title: '金额',
key: 'amount',
width: 110,
align: 'right',
render: (r) => h('span', { class: 'tabular-nums' }, fmtMoney(r.amount, r.currency)),
},
{
title: '状态',
key: 'status',
width: 100,
render: (r) => {
const m = invoiceStatusMeta(r)
return h(StatusBadge, { kind: m.kind, label: m.label })
},
},
{
title: '操作',
key: 'actions',
width: 170,
align: 'right',
render: (r) =>
h('div', { class: 'flex justify-end gap-1' }, [
r.isPayable && !r.isPaid
? h(
NButton,
{ size: 'tiny', type: 'primary', onClick: (e: MouseEvent) => { e.stopPropagation(); openPay(r) } },
{ default: () => '付款' },
)
: null,
h(
NButton,
{ size: 'tiny', quaternary: true, onClick: (e: MouseEvent) => { e.stopPropagation(); openDetail(r) } },
{ default: () => '明细' },
),
h(
NButton,
{ size: 'tiny', quaternary: true, onClick: (e: MouseEvent) => { e.stopPropagation(); openPdf(r) } },
{ default: () => 'PDF' },
),
]),
},
])
const rowProps = (row: Invoice) => ({
style: 'cursor: pointer',
onClick: () => openDetail(row),
})
</script>
<template>
<div class="flex flex-col gap-4">
<div v-if="initialLoading" class="panel flex items-center justify-center py-16">
<NSpin size="small" />
</div>
<div v-else-if="forbidden" class="panel">
<EmptyCard
title="无账单访问权限"
note="当前 API Key 所属用户无 OSP Gateway 权限;需要租户管理员授予 policy:Allow group Administrators to manage osp-gateway in tenancy"
/>
</div>
<div v-else-if="excluded" class="panel">
<EmptyCard
title="账单服务不适用于该租户"
note="OCI 账单平台(OSP Gateway)排除了该租户所属组织(OrganizationExclusionError),常见于巴西等采用当地结算体系的注册地;发票请前往 Oracle 官方控制台或当地结算渠道查询"
/>
</div>
<div v-else-if="loadError" class="panel flex items-center gap-2 px-4.5 py-3 text-xs text-err">
<span class="min-w-0 flex-1 truncate" :title="loadError">{{ loadError }}</span>
<NButton size="tiny" quaternary @click="init">重试</NButton>
</div>
<div v-else-if="!list.length" class="panel">
<EmptyCard
title="暂无发票"
note="Free Tier / Always Free 用量不产生发票;升级为付费账户后,月度发票将在此展示"
/>
</div>
<template v-else>
<div class="grid grid-cols-4 gap-3 max-md:grid-cols-2">
<div class="panel px-4 py-3">
<div class="text-xs text-ink-3">未付余额</div>
<div class="mt-1 text-[20px] font-semibold text-accent tabular-nums">
{{ fmtMoney(unpaidDue, currency) }}
</div>
</div>
<div class="panel px-4 py-3">
<div class="text-xs text-ink-3">本年已开票</div>
<div class="mt-1 text-[20px] font-semibold tabular-nums">
{{ fmtMoney(yearTotal, currency) }}
</div>
</div>
<div class="panel px-4 py-3">
<div class="text-xs text-ink-3">上期已付发票</div>
<div class="mt-1 text-[20px] font-semibold tabular-nums">
{{ lastPaid ? fmtMoney(lastPaid.amount, lastPaid.currency) : '—' }}
</div>
</div>
<div
class="panel cursor-pointer px-4 py-3 transition-colors hover:border-accent/45"
title="查看付款方式"
@click="showMethods = true"
>
<div class="flex items-center justify-between text-xs text-ink-3">
<span>默认付款方式</span>
<span class="text-ink-3"></span>
</div>
<div class="mt-1 truncate text-[15px] font-semibold">{{ methodBrief }}</div>
</div>
</div>
<div class="panel">
<div class="flex 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="text-xs text-ink-3">已加载 {{ list.length }} ,点击行查看费用明细</div>
<div class="flex-1" />
<NButton size="tiny" quaternary @click="init">刷新</NButton>
</div>
<NDataTable
:columns="columns"
:data="list"
:bordered="false"
:bottom-bordered="false"
:row-props="rowProps"
:row-key="(r: Invoice) => r.id"
:scroll-x="720"
size="small"
/>
<div class="flex justify-center border-t border-line-soft py-2">
<NButton
v-if="!exhausted"
size="tiny"
quaternary
:loading="loadingMore"
@click="loadMore"
>
加载 {{ nextYear }} 年发票
</NButton>
<span v-else class="text-xs text-ink-3">已加载全部发票</span>
</div>
</div>
</template>
<InvoiceDetailModal
v-model:show="showDetail"
:cfg-id="cfgId"
:invoice="detailInv"
@pay="payFromDetail"
/>
<InvoicePayModal
v-model:show="showPay"
:cfg-id="cfgId"
:invoice="payInv"
:methods="methods.data.value"
@paid="refreshSilent"
/>
<InvoicePdfModal v-model:show="showPdf" :cfg-id="cfgId" :invoice="pdfInv" />
<PaymentMethodsModal
v-model:show="showMethods"
:methods="methods.data.value"
:loading="methods.loading.value"
:unsupported="methodsUnsupported"
/>
</div>
</template>
+19 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch } from 'naive-ui'
import { NButton, NDynamicTags, NSpin, NSwitch } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
@@ -17,6 +17,7 @@ import {
updateIdentitySetting,
updateNotificationRecipients,
} from '@/api/tenant'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import PolicyEditModal from '@/components/tenant/PolicyEditModal.vue'
@@ -205,14 +206,17 @@ function policyDesc(p: PasswordPolicy): string {
<span class="h-1.5 w-1.5 rounded-full" :class="relayChip.dot" />
{{ relayChip.label }}
</span>
<NPopconfirm v-if="relayBuilt" @positive-click="destroyRelay">
<ConfirmPop
v-if="relayBuilt"
title="销毁回传链路?"
content="将删除租户内的 Connector、Policy、订阅与 Topic 并撤销回调地址。"
confirm-text="销毁"
:on-confirm="destroyRelay"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary :loading="tearingRelay">
销毁链路
</NButton>
<NButton size="tiny" type="error" quaternary>销毁链路</NButton>
</template>
将删除租户内的 ConnectorPolicy订阅与 Topic 并撤销回调地址,确认销毁?
</NPopconfirm>
</ConfirmPop>
<NButton
v-else
size="small"
@@ -264,14 +268,16 @@ function policyDesc(p: PasswordPolicy): string {
{{ webhook.data.value.webhook?.path }}
</span>
<NButton size="tiny" quaternary class="flex-none" @click="copyWebhookPath">复制</NButton>
<NPopconfirm @positive-click="removeWebhook">
<ConfirmPop
title="撤销 Webhook 地址?"
content="撤销后旧回调地址立即 404,OCI 侧订阅将投递失败。"
confirm-text="撤销"
:on-confirm="removeWebhook"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary class="flex-none" :loading="savingWebhook">
撤销
</NButton>
<NButton size="tiny" type="error" quaternary class="flex-none">撤销</NButton>
</template>
撤销后旧回调地址立即 404,OCI 侧订阅将投递失败,确认撤销?
</NPopconfirm>
</ConfirmPop>
</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">
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { NModal, NSpin } from 'naive-ui'
import type { PaymentMethod } from '@/types/api'
/** 付款方式弹窗:只读展示订阅上登记的方式。
* OSP Gateway 不提供删卡 API,加卡/换卡走 OCI 托管付款页(面板不经手卡号),
* 故这里只展示并引导到 Billing Center。 */
defineProps<{
show: boolean
methods: PaymentMethod[] | null
loading: boolean
/** 非 PAYG 计划(InvalidPlanType):没有自助付款方式,与「未登记」区分展示 */
unsupported?: boolean
}>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
function iconText(m: PaymentMethod): string {
if (m.method !== 'CREDIT_CARD') return 'PP'
return (m.cardType || 'CARD').slice(0, 4)
}
function title(m: PaymentMethod): string {
if (m.method === 'CREDIT_CARD') return `${m.cardType || '银行卡'} •••• ${m.lastDigits || '????'}`
return m.email || 'PayPal 账户'
}
function subtitle(m: PaymentMethod): string {
if (m.method === 'CREDIT_CARD') {
const exp = m.timeExpiration ? `到期 ${m.timeExpiration.slice(0, 7)}` : ''
return [m.nameOnCard, exp].filter(Boolean).join(' · ')
}
const ba = m.billingAgreement ? `扣款协议 ${m.billingAgreement}` : ''
return [m.payerName, ba].filter(Boolean).join(' · ')
}
</script>
<template>
<NModal
:show="show"
preset="card"
title="付款方式"
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<div class="-mt-1 mb-2 text-xs text-ink-3">自动扣款与在线付款按订阅默认方式执行</div>
<div v-if="loading" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<template v-else-if="methods?.length">
<div
v-for="(m, i) in methods"
:key="i"
class="flex items-center gap-3 border-b border-line-soft py-3 last:border-b-0"
>
<div
class="flex h-8 w-12 flex-none items-center justify-center rounded border border-line-soft bg-wash text-[10px] font-bold tracking-wide text-ink-2"
>
{{ iconText(m) }}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{{ title(m) }}</div>
<div class="mt-0.5 truncate text-xs text-ink-3">{{ subtitle(m) }}</div>
</div>
<span
class="flex-none rounded border border-line-soft px-1.5 py-0.5 text-[10.5px] text-ink-3"
>
{{ m.method }}
</span>
</div>
</template>
<div v-else-if="unsupported" class="py-8 text-center text-[12.5px] text-ink-3">
当前订阅计划不属于 PAYG(InvalidPlanType),无自助付款方式;
合约/伙伴结算账号的付款安排请联系 Oracle 销售或查看合同条款
</div>
<div v-else class="py-8 text-center text-[12.5px] text-ink-3">
未登记付款方式(Free Tier 常见);添加卡片请前往 OCI Billing Center
</div>
<div class="mt-3 rounded-lg bg-wash px-3 py-2.5 text-xs leading-relaxed text-ink-3">
添加或更换卡片需经 OCI 托管付款页完成,面板不经手卡号;请在 OCI 控制台Billing &amp;
Cost Management Payment Methods操作,完成后回本页刷新即可
</div>
</NModal>
</template>
+15 -5
View File
@@ -127,19 +127,29 @@ const baseColumns: DataTableColumns<LimitItem> = [
},
]
/** 字节类配额可达百亿级,列宽须容纳千分位全量数值,禁止折行 */
const availColumns: DataTableColumns<LimitItem> = [
{
title: '已用',
key: 'used',
width: 80,
render: (r) => h('span', { class: 'tabular-nums' }, r.used === undefined ? '—' : String(r.used)),
width: 120,
render: (r) =>
h(
'span',
{ class: 'tabular-nums whitespace-nowrap' },
r.used === undefined ? '—' : r.used.toLocaleString('en-US'),
),
},
{
title: '可用',
key: 'available',
width: 80,
width: 120,
render: (r) =>
h('span', { class: 'tabular-nums' }, r.available === undefined ? '—' : String(r.available)),
h(
'span',
{ class: 'tabular-nums whitespace-nowrap' },
r.available === undefined ? '—' : r.available.toLocaleString('en-US'),
),
},
{
title: '用量',
@@ -221,7 +231,7 @@ const columns = computed(() => (withAvail.value ? [...baseColumns, ...availColum
:data="rows"
:loading="limits.loading.value"
:pagination="pagination"
:scroll-x="withAvail ? 660 : 380"
:scroll-x="withAvail ? 760 : 380"
:row-key="(r: LimitItem) => r.name + (r.availabilityDomain ?? '')"
/>
<FootNote>
+3 -5
View File
@@ -5,6 +5,7 @@ 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 EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import SubscribeRegionModal from '@/components/tenant/SubscribeRegionModal.vue'
@@ -196,11 +197,8 @@ function subLabel(d: SubscriptionDetail): string {
<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 v-else-if="!subs.data.value?.length" class="panel">
<EmptyCard title="未获取到订阅信息" note="Organizations API 可能对该租户不可用" />
</div>
<div v-for="d in subs.data.value" v-else :key="d.id" class="panel">
+13 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, useDialog, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import { clearUserMfa, deleteUser, deleteUserApiKeys, listUsers, resetUserPassword } from '@/api/tenant'
@@ -9,6 +9,7 @@ import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { IamUser } from '@/types/api'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number; domainId?: string }>()
@@ -117,25 +118,30 @@ const columns: DataTableColumns<IamUser> = [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => resetPwd(row) }, { default: () => '重置密码' }),
h(
NPopconfirm,
ConfirmPop,
{
onPositiveClick: () =>
title: '清除全部 MFA 因子?',
content: `${row.name} 下次登录需重新注册 MFA。`,
kind: 'warn',
confirmText: '清除',
onConfirm: () =>
act(() => clearUserMfa(props.cfgId, row.id, props.domainId), `已清除 ${row.name} 的全部 MFA`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
default: () => '清除该用户全部 MFA 因子?',
},
),
h(
NPopconfirm,
ConfirmPop,
{
onPositiveClick: () =>
title: '删除全部 API Key',
content: '跳过当前配置使用中的指纹。',
confirmText: '删除',
onConfirm: () =>
act(() => deleteUserApiKeys(props.cfgId, row.id), `已删除 ${row.name} 的 API Key`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删 Key' }),
default: () => '删除该用户全部 API Key(跳过当前配置使用中的指纹)?',
},
),
row.isCurrentUser
+18
View File
@@ -0,0 +1,18 @@
import type { Invoice } from '@/types/api'
/** 发票状态 → 徽章形态与中文标签(isPaid 优先于 status 判定) */
export function invoiceStatusMeta(inv: Invoice): {
kind: 'warn' | 'term' | 'prov' | 'run' | 'check'
label: string
} {
if (inv.isPaid || inv.status === 'CLOSED') return { kind: 'run', label: '已付' }
if (inv.status === 'PAST_DUE') return { kind: 'term', label: '逾期' }
if (inv.status === 'PAYMENT_SUBMITTED') return { kind: 'prov', label: '处理中' }
if (inv.status === 'OPEN') return { kind: 'warn', label: '未付' }
return { kind: 'check', label: inv.status || '未知' }
}
/** ISO 时间取日期段;发票的开票/到期语义是日期,不做时区换算 */
export function invoiceDate(iso: string | null): string {
return iso ? iso.slice(0, 10) : '—'
}