@@ -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>
|
||||
Reference in New Issue
Block a user