Files
oci-portal-dash/src/components/tenant/InvoiceTab.vue
T

349 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>