新增对象存储/账单/保留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
+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>