@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
将删除租户内的 Connector、Policy、订阅与 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 &
|
||||
Cost Management → Payment Methods」操作,完成后回本页刷新即可。
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) : '—'
|
||||
}
|
||||
Reference in New Issue
Block a user