账单页面及弹窗基于naive ui重构

This commit is contained in:
jingrow 2025-12-30 02:48:45 +08:00
parent bbacb2deb6
commit 31b392b359
10 changed files with 1492 additions and 903 deletions

View File

@ -1,15 +1,25 @@
<template> <template>
<Dialog v-model="show" :options="{ title: $t('Account Recharge') }"> <n-modal
<template #body-content> v-model:show="show"
<div preset="card"
:title="$t('Account Recharge')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="add-prepaid-credits-modal"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Account Recharge') }}</span>
</template>
<n-space vertical :size="20">
<n-alert
v-if="showMessage" v-if="showMessage"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700" type="info"
:title="$t('Information')"
closable
> >
<FeatherIcon class="h-4" name="info" /> {{ $t('Please recharge your account balance before changing payment method.') }}
<span> </n-alert>
{{ $t('Please recharge your account balance before changing payment method.') }}
</span>
</div>
<PrepaidCreditsForm <PrepaidCreditsForm
@success=" @success="
() => { () => {
@ -18,12 +28,13 @@
} }
" "
/> />
</template> </n-space>
</Dialog> </n-modal>
</template> </template>
<script setup> <script setup>
import { NModal, NSpace, NAlert } from 'naive-ui';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import PrepaidCreditsForm from '../BuyPrepaidCreditsForm.vue'; import PrepaidCreditsForm from '../BuyPrepaidCreditsForm.vue';
import { Dialog, FeatherIcon } from 'jingrow-ui';
const props = defineProps({ const props = defineProps({
showMessage: { showMessage: {
@ -34,4 +45,53 @@ const props = defineProps({
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const show = defineModel(); const show = defineModel();
</script> const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '700px',
maxWidth: isMobile.value ? '95vw' : '90vw',
}));
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
:deep(.add-prepaid-credits-modal .n-card) {
width: 700px;
max-width: 90vw;
}
:deep(.add-prepaid-credits-modal .n-card-body) {
padding: 24px;
}
@media (max-width: 768px) {
:deep(.add-prepaid-credits-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.add-prepaid-credits-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.add-prepaid-credits-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
}
</style>

View File

@ -1,13 +1,25 @@
<template> <template>
<Dialog v-model="show" :options="{ title: $t('Billing Details') }"> <n-modal
<template #body-content> v-model:show="show"
<div preset="card"
:title="$t('Billing Details')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="billing-details-modal"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Billing Details') }}</span>
</template>
<n-space vertical :size="20">
<n-alert
v-if="showMessage" v-if="showMessage"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700" type="info"
:title="$t('Information')"
closable
> >
<FeatherIcon class="h-4" name="info" /> {{ $t('Please add billing details to your account before continuing.') }}
<span> {{ $t('Please add billing details to your account before continuing.') }}</span> </n-alert>
</div>
<BillingDetails <BillingDetails
ref="billingRef" ref="billingRef"
@success=" @success="
@ -17,13 +29,13 @@
} }
" "
/> />
</template> </n-space>
</Dialog> </n-modal>
</template> </template>
<script setup> <script setup>
import { NModal, NSpace, NAlert } from 'naive-ui';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import BillingDetails from './BillingDetails.vue'; import BillingDetails from './BillingDetails.vue';
import { FeatherIcon, Dialog } from 'jingrow-ui';
import { ref } from 'vue';
const props = defineProps({ const props = defineProps({
showMessage: { showMessage: {
@ -31,7 +43,57 @@ const props = defineProps({
default: false default: false
} }
}); });
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const show = defineModel(); const show = defineModel();
const billingRef = ref(null); const billingRef = ref(null);
</script> const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '700px',
maxWidth: isMobile.value ? '95vw' : '90vw',
}));
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
:deep(.billing-details-modal .n-card) {
width: 700px;
max-width: 90vw;
}
:deep(.billing-details-modal .n-card-body) {
padding: 24px;
}
@media (max-width: 768px) {
:deep(.billing-details-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.billing-details-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.billing-details-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
}
</style>

View File

@ -1,56 +1,65 @@
<template> <template>
<div class="flex flex-col gap-4 py-10"> <n-space vertical :size="24">
<div class="flex flex-col"> <n-card class="payment-details-card" :title="$t('Account Balance')">
<div class="flex items-center justify-between text-base text-gray-900"> <n-space vertical :size="16">
<div class="flex flex-col gap-1.5"> <div class="balance-section">
<div class="text-lg font-semibold text-gray-900">{{ $t('Account Balance') }}</div> <div class="balance-info">
<div class="text-2xl font-bold text-blue-600 py-6"> <div class="balance-amount">
{{ availableCredits || currency + ' 0.00' }} {{ availableCredits || currency + ' 0.00' }}
</div>
</div> </div>
</div> <n-button
<div class="shrink-0"> type="primary"
<Button
:label="$t('Recharge')"
@click=" @click="
() => { () => {
showMessage = false; showMessage = false;
showAddPrepaidCreditsDialog = true; showAddPrepaidCreditsDialog = true;
} }
" "
:size="buttonSize"
:block="isMobile"
> >
<template #prefix> <template #icon>
<FeatherIcon class="h-4" name="plus" /> <i-lucide-plus class="h-4 w-4" />
</template> </template>
</Button> {{ $t('Recharge') }}
</n-button>
</div> </div>
</div> </n-space>
<div class="my-3 h-px bg-gray-100" /> </n-card>
<div class="flex items-center justify-between text-base text-gray-900">
<div class="flex flex-col gap-1.5"> <n-card class="payment-details-card" :title="$t('Billing Address')">
<div class="font-medium">{{ $t('Billing Address') }}</div> <n-space vertical :size="16">
<div v-if="billingDetailsSummary" class="leading-5 text-gray-700"> <div class="address-section">
{{ billingDetailsSummary }} <div class="address-info">
<div v-if="billingDetailsSummary" class="address-text">
{{ billingDetailsSummary }}
</div>
<div v-else class="address-empty">
{{ $t('No address') }}
</div>
</div> </div>
<div v-else class="text-gray-700">{{ $t('No address') }}</div> <n-button
</div> :type="billingDetailsSummary ? 'default' : 'primary'"
<div class="shrink-0">
<Button
:label="billingAddressButtonLabel"
@click=" @click="
() => { () => {
showMessage = false; showMessage = false;
showBillingDetailsDialog = true; showBillingDetailsDialog = true;
} }
" "
:size="buttonSize"
:block="isMobile"
> >
<template v-if="!billingDetailsSummary" #prefix> <template v-if="!billingDetailsSummary" #icon>
<FeatherIcon class="h-4" name="plus" /> <i-lucide-plus class="h-4 w-4" />
</template> </template>
</Button> {{ billingAddressButtonLabel }}
</n-button>
</div> </div>
</div> </n-space>
</div> </n-card>
</div> </n-space>
<BillingDetailsDialog <BillingDetailsDialog
v-if="showBillingDetailsDialog" v-if="showBillingDetailsDialog"
v-model="showBillingDetailsDialog" v-model="showBillingDetailsDialog"
@ -65,16 +74,11 @@
/> />
</template> </template>
<script setup> <script setup>
import DropdownItem from './DropdownItem.vue'; import { NCard, NSpace, NButton } from 'naive-ui';
import BillingDetailsDialog from './BillingDetailsDialog.vue'; import BillingDetailsDialog from './BillingDetailsDialog.vue';
import AddPrepaidCreditsDialog from './AddPrepaidCreditsDialog.vue'; import AddPrepaidCreditsDialog from './AddPrepaidCreditsDialog.vue';
import { Dropdown, Button, FeatherIcon, createResource } from 'jingrow-ui'; import { createResource } from 'jingrow-ui';
import { import { computed, ref, inject, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
confirmDialog,
renderDialog,
} from '../../utils/components';
import { computed, ref, inject, h, defineAsyncComponent, onMounted, nextTick, getCurrentInstance } from 'vue';
import router from '../../router';
// //
const countryToZh = { const countryToZh = {
@ -96,6 +100,10 @@ const {
const showBillingDetailsDialog = ref(false); const showBillingDetailsDialog = ref(false);
const showAddPrepaidCreditsDialog = ref(false); const showAddPrepaidCreditsDialog = ref(false);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const currency = computed(() => (team.pg.currency == 'CNY' ? '¥' : '$')); const currency = computed(() => (team.pg.currency == 'CNY' ? '¥' : '$'));
@ -193,8 +201,14 @@ const billingAddressButtonLabel = computed(() => {
return billingDetailsSummary.value ? $t('Edit') : $t('Add Billing Address'); return billingDetailsSummary.value ? $t('Edit') : $t('Add Billing Address');
}); });
function handleResize() {
windowWidth.value = window.innerWidth;
}
// onMounted // onMounted
onMounted(() => { onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
// //
nextTick(() => { nextTick(() => {
if (!team.pg.payment_mode) { if (!team.pg.payment_mode) {
@ -203,6 +217,10 @@ onMounted(() => {
}); });
}); });
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
function payUnpaidInvoices() { function payUnpaidInvoices() {
let _unpaidInvoices = unpaidInvoices.data; let _unpaidInvoices = unpaidInvoices.data;
if (_unpaidInvoices.length > 1) { if (_unpaidInvoices.length > 1) {
@ -231,4 +249,63 @@ function updatePaymentMode(mode) {
} }
if (!changePaymentMode.loading) changePaymentMode.submit({ mode }); if (!changePaymentMode.loading) changePaymentMode.submit({ mode });
} }
</script> </script>
<style scoped>
.payment-details-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
}
.payment-details-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.balance-section,
.address-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.balance-info,
.address-info {
flex: 1;
}
.balance-amount {
font-size: 32px;
font-weight: 700;
color: var(--n-color-primary);
line-height: 1.2;
}
.address-text {
font-size: 14px;
color: var(--n-text-color);
line-height: 1.6;
word-break: break-word;
}
.address-empty {
font-size: 14px;
color: var(--n-text-color-3);
font-style: italic;
}
@media (min-width: 769px) {
.balance-section,
.address-section {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
@media (max-width: 768px) {
.balance-amount {
font-size: 24px;
}
}
</style>

View File

@ -1,68 +1,178 @@
<template> <template>
<div class="sticky top-0 z-10 shrink-0"> <div class="billing-container">
<Header> <Header>
<FBreadcrumbs <n-breadcrumb>
:items="[{ label: $t('Billing'), route: { name: 'Billing' } }]" <n-breadcrumb-item>
/> <router-link :to="{ name: 'Billing' }">
{{ $t('Billing') }}
</router-link>
</n-breadcrumb-item>
</n-breadcrumb>
</Header> </Header>
<TabsWithRouter <div v-if="$team?.pg?.is_desk_user || $session.hasBillingAccess" class="billing-content">
v-if="$team?.pg?.is_desk_user || $session.hasBillingAccess" <n-tabs
:tabs="filteredTabs" v-model:value="activeTab"
/> type="line"
<div animated
v-else :bar-width="isMobile ? 0 : undefined"
class="mx-auto mt-60 w-fit rounded border border-dashed px-12 py-8 text-center text-gray-600" :size="isMobile ? 'medium' : 'large'"
> class="billing-tabs"
<i-lucide-alert-triangle class="mx-auto mb-4 h-6 w-6 text-red-600" /> >
<ErrorMessage :message="$t('You do not have permission to view the billing page')" /> <n-tab-pane
v-for="tab in filteredTabs"
:key="tab.route.name"
:name="tab.route.name"
:tab="tab.label"
>
<router-view :tab="tab" />
</n-tab-pane>
</n-tabs>
</div>
<div v-else class="no-permission-container">
<n-result
status="403"
:title="$t('Access Denied')"
:description="$t('You do not have permission to view the billing page')"
>
<template #icon>
<i-lucide-alert-triangle class="h-12 w-12 text-red-600" />
</template>
</n-result>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
import { Tabs, Breadcrumbs } from 'jingrow-ui'; import { computed, ref, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NTabs, NTabPane, NBreadcrumb, NBreadcrumbItem, NResult } from 'naive-ui';
import Header from '../components/Header.vue'; import Header from '../components/Header.vue';
import TabsWithRouter from '../components/TabsWithRouter.vue';
export default { const instance = getCurrentInstance();
name: 'Billing', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
components: { const $team = instance?.appContext.config.globalProperties.$team;
Header, const $session = instance?.appContext.config.globalProperties.$session;
FBreadcrumbs: Breadcrumbs,
FTabs: Tabs, const route = useRoute();
TabsWithRouter, const router = useRouter();
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const tabs = [
{ label: $t('Overview'), route: { name: 'BillingOverview' } },
{ label: $t('Order Records'), route: { name: 'BillingOrders' } },
{ label: $t('Balance Details'), route: { name: 'BillingBalances' } },
{
label: $t('Developer Earnings'),
route: { name: 'BillingMarketplacePayouts' },
requireDeveloper: true,
requirePro: true
}, },
data() { ];
return {
currentTab: 0, const filteredTabs = computed(() => {
tabs: [ return tabs.filter(tab => {
{ label: this.$t('Overview'), route: { name: 'BillingOverview' } }, //
{ label: this.$t('Order Records'), route: { name: 'BillingOrders' } }, if (tab.requireDeveloper && !$team.pg.is_developer) {
{ label: this.$t('Balance Details'), route: { name: 'BillingBalances' } }, return false;
{
label: this.$t('Developer Earnings'),
route: { name: 'BillingMarketplacePayouts' },
requireDeveloper: true,
requirePro: true
},
],
};
},
computed: {
filteredTabs() {
return this.tabs.filter(tab => {
//
if (tab.requireDeveloper && !this.$team.pg.is_developer) {
return false;
}
//
if (tab.requirePro && !this.$team.pg.is_pro) {
return false;
}
return true;
});
} }
//
if (tab.requirePro && !$team.pg.is_pro) {
return false;
}
return true;
});
});
const activeTab = computed({
get() {
return route.name || 'BillingOverview';
}, },
}; set(value) {
</script> router.push({ name: value });
}
});
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.billing-container {
width: 100%;
display: flex;
flex-direction: column;
}
.billing-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.billing-tabs {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.n-tabs-nav) {
padding: 0 24px;
background: white;
border-bottom: 1px solid var(--n-border-color);
}
:deep(.n-tabs-tab) {
padding: 12px 16px;
font-size: 15px;
font-weight: 500;
}
:deep(.n-tabs-tab--active) {
color: var(--n-color);
}
:deep(.n-tab-pane) {
padding: 0;
overflow-y: auto;
}
.no-permission-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 24px;
}
@media (max-width: 768px) {
:deep(.n-tabs-nav) {
padding: 0 16px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
:deep(.n-tabs-tab) {
padding: 10px 12px;
font-size: 14px;
white-space: nowrap;
}
:deep(.n-tabs-nav-scroll-content) {
display: flex;
}
}
</style>

View File

@ -1,205 +1,233 @@
<template> <template>
<div class="p-5"> <div class="billing-balances-container">
<ObjectList :options="options"> <n-card class="balances-card">
<template #header-right> <ObjectList :options="options">
<Button <template #header-right>
icon="download" <n-button
appearance="primary" type="primary"
@click="exportToCsv" :size="buttonSize"
:loading="exporting" :loading="exporting"
> :block="isMobile"
{{ $t('Export') }} @click="exportToCsv"
</Button> >
</template> <template #icon>
</ObjectList> <i-lucide-download class="h-4 w-4" />
</template>
{{ $t('Export') }}
</n-button>
</template>
</ObjectList>
</n-card>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { NCard, NButton } from 'naive-ui';
import ObjectList from '../components/ObjectList.vue'; import ObjectList from '../components/ObjectList.vue';
import { Button, createResource } from 'jingrow-ui'; import { createResource } from 'jingrow-ui';
import { unparse } from 'papaparse'; import { unparse } from 'papaparse';
export default { const instance = getCurrentInstance();
name: 'BillingBalances', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
props: ['tab'], const $team = instance?.appContext.config.globalProperties.$team;
components: {
ObjectList,
Button
},
data() {
return {
exporting: false
};
},
computed: {
options() {
return {
pagetype: 'Balance Transaction',
fields: ['type', 'source', 'invoice', 'description'],
columns: [
{
label: this.$t('Time'),
fieldname: 'creation',
format(value) {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
}
},
{
label: this.$t('Description'),
fieldname: 'description',
format(value, row) {
if (value !== null && value !== undefined) {
return value;
}
if (row.type === 'Applied To Invoice' && row.invoice) {
return this.$t('Applied to Invoice {invoice}', { invoice: row.invoice });
}
if (row.source === 'Prepaid Credits') { const exporting = ref(false);
return this.$t('Balance Recharge'); const windowWidth = ref(window.innerWidth);
}
if (row.source === 'Free Credits') { const isMobile = computed(() => windowWidth.value <= 768);
return this.$t('Free Credits'); const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
}
return row.amount < 0 ? row.type : row.source; const props = defineProps(['tab']);
} const options = computed(() => {
}, return {
{ pagetype: 'Balance Transaction',
label: this.$t('Amount'), fields: ['type', 'source', 'invoice', 'description'],
fieldname: 'amount', columns: [
align: 'right', {
format: this.formatCurrency label: $t('Time'),
}, fieldname: 'creation',
{ format(value) {
label: this.$t('Balance'), return new Date(value).toLocaleString('zh-CN', {
fieldname: 'ending_balance', year: 'numeric',
align: 'right', month: '2-digit',
format: this.formatCurrency day: '2-digit',
} hour: '2-digit',
], minute: '2-digit',
filters: { second: '2-digit'
pagestatus: 1, }).replace(/\//g, '-');
team: this.$team.name
},
orderBy: 'creation desc'
};
}
},
created() {
//
this.exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: this.handleExportSuccess,
onError: this.handleExportError
});
},
methods: {
formatCurrency(value) {
if (value === 0) {
return '';
}
return this.$format.userCurrency(value);
},
formatDate(dateString) {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
},
exportToCsv() {
this.exporting = true;
this.exportResource.submit({
page: 1,
page_size: 1000
});
},
handleExportSuccess(response) {
const transactions = response.transactions || [];
// CSV
const fields = [
this.$t('Time'),
this.$t('Description'),
this.$t('Amount'),
this.$t('Balance')
];
//
const csvData = transactions.map(row => {
// 使
let description = row.description;
// 使
if (!description) {
if (row.type === 'Applied To Invoice' && row.invoice) {
description = this.$t('Applied to Invoice {invoice}', { invoice: row.invoice });
} else if (row.source === 'Prepaid Credits') {
description = this.$t('Balance Recharge');
} else if (row.source === 'Free Credits') {
description = this.$t('Free Credits');
} else {
description = row.amount < 0 ? row.type : row.source;
}
} }
},
return [ {
this.formatDate(row.creation), label: $t('Description'),
description, fieldname: 'description',
row.amount, format(value, row) {
row.ending_balance if (value !== null && value !== undefined) {
]; return value;
}); }
// if (row.type === 'Applied To Invoice' && row.invoice) {
csvData.unshift(fields); return $t('Applied to Invoice {invoice}', { invoice: row.invoice });
}
// CSV
let csv = unparse(csvData); if (row.source === 'Prepaid Credits') {
return $t('Balance Recharge');
// BOM }
csv = '\uFEFF' + csv;
if (row.source === 'Free Credits') {
// return $t('Free Credits');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); }
const today = new Date().toISOString().split('T')[0];
const filename = `${this.$t('Balance Records')}-${today}.csv`; return row.amount < 0 ? row.type : row.source;
const link = document.createElement('a'); }
link.href = URL.createObjectURL(blob); },
link.download = filename; {
link.click(); label: $t('Amount'),
URL.revokeObjectURL(link.href); fieldname: 'amount',
align: 'right',
this.exporting = false; format: formatCurrency
},
{
label: $t('Balance'),
fieldname: 'ending_balance',
align: 'right',
format: formatCurrency
}
],
filters: {
pagestatus: 1,
team: $team.name
}, },
handleExportError(error) { orderBy: 'creation desc'
console.error(this.$t('Failed to export data'), error); };
this.exporting = false; });
}
//
const exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: handleExportSuccess,
onError: handleExportError
});
function formatCurrency(value) {
if (value === 0) {
return '';
} }
}; const $format = instance?.appContext.config.globalProperties.$format;
</script> return $format?.userCurrency(value) || value;
<style scoped>
/* 添加悬浮效果 */
.flex-col:hover {
background-color: #f0f0f0; /* 可按需调整颜色 */
} }
/* 允许内容复制 */ function formatDate(dateString) {
.flex-col * { return new Date(dateString).toLocaleString('zh-CN', {
user-select: text; year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
}
function exportToCsv() {
exporting.value = true;
exportResource.submit({
page: 1,
page_size: 1000
});
}
function handleExportSuccess(response) {
const transactions = response.transactions || [];
// CSV
const fields = [
$t('Time'),
$t('Description'),
$t('Amount'),
$t('Balance')
];
//
const csvData = transactions.map(row => {
// 使
let description = row.description;
// 使
if (!description) {
if (row.type === 'Applied To Invoice' && row.invoice) {
description = $t('Applied to Invoice {invoice}', { invoice: row.invoice });
} else if (row.source === 'Prepaid Credits') {
description = $t('Balance Recharge');
} else if (row.source === 'Free Credits') {
description = $t('Free Credits');
} else {
description = row.amount < 0 ? row.type : row.source;
}
}
return [
formatDate(row.creation),
description,
row.amount,
row.ending_balance
];
});
//
csvData.unshift(fields);
// CSV
let csv = unparse(csvData);
// BOM
csv = '\uFEFF' + csv;
//
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const today = new Date().toISOString().split('T')[0];
const filename = `${$t('Balance Records')}-${today}.csv`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
exporting.value = false;
}
function handleExportError(error) {
console.error($t('Failed to export data'), error);
exporting.value = false;
}
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.billing-balances-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.balances-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
@media (max-width: 768px) {
.billing-balances-container {
padding: 16px;
}
} }
</style> </style>

View File

@ -1,105 +1,171 @@
<template> <template>
<div class="p-5"> <div class="billing-marketplace-payouts-container">
<ObjectList :options="options" /> <n-card class="payouts-card">
<Dialog <ObjectList :options="options" />
v-model="payoutDialog" </n-card>
:options="{ size: '6xl', title: showPayout?.name }" <n-modal
v-model:show="payoutDialog"
preset="card"
:title="showPayout?.name"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="payout-dialog"
> >
<template #body-content> <template #header>
<template v-if="showPayout"> <span class="text-lg font-semibold">{{ showPayout?.name }}</span>
<div
v-if="showPayout.status === 'Empty'"
class="text-base text-gray-600"
>
{{ $t('No content to display') }}
</div>
<PayoutTable v-else :payoutId="showPayout.name" />
</template>
</template> </template>
</Dialog> <template v-if="showPayout">
<n-empty
v-if="showPayout.status === 'Empty'"
:description="$t('No content to display')"
/>
<PayoutTable v-else :payoutId="showPayout.name" />
</template>
</n-modal>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { NCard, NModal, NEmpty } from 'naive-ui';
import ObjectList from '../components/ObjectList.vue'; import ObjectList from '../components/ObjectList.vue';
import PayoutTable from '../components/PayoutTable.vue'; import PayoutTable from '../components/PayoutTable.vue';
export default { const instance = getCurrentInstance();
name: 'BillingMarketplacePayouts', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
props: ['tab'], const $team = instance?.appContext.config.globalProperties.$team;
data() {
return {
payoutDialog: false,
showPayout: null
};
},
components: {
ObjectList,
PayoutTable
},
computed: {
options() {
return {
pagetype: 'Payout Order',
fields: [
'period_end',
'mode_of_payment',
'status',
'net_total_cny',
'net_total_usd'
],
filterControls: () => {
return [
{
type: 'select',
label: this.$t('Status'),
class: !this.$isMobile ? 'w-36' : '',
fieldname: 'status',
options: [
{ label: '', value: '' },
{ label: this.$t('Pending Settlement'), value: 'Draft' },
{ label: this.$t('Paid'), value: 'Paid' },
{ label: this.$t('Settled'), value: 'Commissioned' }
]
}
];
},
orderBy: 'creation desc',
columns: [
{
label: this.$t('Date'),
fieldname: 'period_end',
format(value) {
return Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(value));
}
},
{ label: this.$t('Payment Method'), fieldname: 'mode_of_payment' },
{ label: this.$t('Status'), fieldname: 'status', type: 'Badge' },
{
label: this.$t('Total'),
fieldname: 'net_total_cny',
align: 'right',
format: (_, row) => {
let total = 0;
if (this.$team.pg.currency === 'CNY') {
total = row.net_total_cny + row.net_total_usd * 82;
} else {
total = row.net_total_cny / 82 + row.net_total_usd;
}
return this.$format.userCurrency(total); const props = defineProps(['tab']);
}
} const payoutDialog = ref(false);
], const showPayout = ref(null);
onRowClick: row => { const windowWidth = ref(window.innerWidth);
this.showPayout = row;
this.payoutDialog = true; const isMobile = computed(() => windowWidth.value <= 768);
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '1200px',
maxWidth: isMobile.value ? '95vw' : '90vw',
}));
const options = computed(() => {
return {
pagetype: 'Payout Order',
fields: [
'period_end',
'mode_of_payment',
'status',
'net_total_cny',
'net_total_usd'
],
filterControls: () => {
return [
{
type: 'select',
label: $t('Status'),
class: !isMobile.value ? 'w-36' : '',
fieldname: 'status',
options: [
{ label: '', value: '' },
{ label: $t('Pending Settlement'), value: 'Draft' },
{ label: $t('Paid'), value: 'Paid' },
{ label: $t('Settled'), value: 'Commissioned' }
]
} }
}; ];
},
orderBy: 'creation desc',
columns: [
{
label: $t('Date'),
fieldname: 'period_end',
format(value) {
return Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(value));
}
},
{ label: $t('Payment Method'), fieldname: 'mode_of_payment' },
{ label: $t('Status'), fieldname: 'status', type: 'Badge' },
{
label: $t('Total'),
fieldname: 'net_total_cny',
align: 'right',
format: (_, row) => {
let total = 0;
const $format = instance?.appContext.config.globalProperties.$format;
if ($team.pg.currency === 'CNY') {
total = row.net_total_cny + row.net_total_usd * 82;
} else {
total = row.net_total_cny / 82 + row.net_total_usd;
}
return $format?.userCurrency(total) || total;
}
}
],
onRowClick: row => {
showPayout.value = row;
payoutDialog.value = true;
} }
};
});
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.billing-marketplace-payouts-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.payouts-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
:deep(.payout-dialog .n-card) {
width: 1200px;
max-width: 90vw;
}
:deep(.payout-dialog .n-card-body) {
padding: 24px;
}
@media (max-width: 768px) {
.billing-marketplace-payouts-container {
padding: 16px;
} }
};
</script> :deep(.payout-dialog .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.payout-dialog .n-card-body) {
padding: 20px 16px;
}
:deep(.payout-dialog .n-card__header) {
padding: 16px;
font-size: 18px;
}
}
</style>

View File

@ -1,147 +1,224 @@
<template> <template>
<div class="p-4"> <div class="billing-orders-container">
<div class="mb-4 flex justify-between"> <n-card class="orders-card">
<div> <template #header>
<FInput <div class="orders-header">
v-model="filters.search" <n-input
:placeholder="$t('Search orders...')" v-model:value="filters.search"
type="text" :placeholder="$t('Search orders...')"
class="w-60" clearable
@input="debouncedSearch" :size="inputSize"
> class="search-input"
<template #prefix> @input="debouncedSearch"
<i-lucide-search class="h-4 w-4 text-gray-500" />
</template>
</FInput>
</div>
<div class="flex items-center space-x-2">
<Button
icon="download"
appearance="primary"
@click="exportToCsv"
>
{{ $t('Export') }}
</Button>
<Button
icon="refresh-cw"
appearance="minimal"
@click="resetAndFetch"
/>
</div>
</div>
<Card class="overflow-hidden !rounded-none !border-0">
<template v-if="orders.length === 0">
<div class="flex h-60 flex-col items-center justify-center space-y-2 p-4 text-center">
<i-lucide-file-text class="h-8 w-8 text-gray-400" />
<p class="text-base font-medium">{{ $t('No Order Records') }}</p>
<p class="text-sm text-gray-600">{{ $t('No order records to display') }}</p>
</div>
</template>
<template v-else>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b text-left text-sm font-medium text-gray-600">
<th
v-for="column in columns"
:key="column.key"
class="whitespace-nowrap px-4 py-3"
:class="column.class"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="order in orders"
:key="order.name || order.order_id"
class="border-b text-sm hover:bg-gray-50"
>
<td class="px-4 py-3">{{ formatDate(order.creation) }}</td>
<td class="px-4 py-3">{{ order.title || '-' }}</td>
<td class="px-4 py-3">{{ order.order_id || '-' }}</td>
<td class="px-4 py-3">{{ order.trade_no || '-' }}</td>
<td class="px-4 py-3">{{ order.order_type || '-' }}</td>
<td class="px-4 py-3">{{ order.payment_method || '-' }}</td>
<td class="px-4 py-3">{{ order.description || '-' }}</td>
<td class="px-4 py-3 text-right">{{ formatCurrency(order.total) }}</td>
<td class="px-4 py-3">
<StatusIndicator :status="getStatusProps(order.status)" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between p-4">
<div class="text-sm text-gray-600">
{{ $t('Showing {count} orders, total {total}', { count: orders.length, total: totalCount }) }}
</div>
<Button
v-if="hasMoreToLoad"
@click="loadMore"
:loading="loadingMore"
appearance="primary"
> >
{{ $t('Load More') }} <template #prefix>
</Button> <i-lucide-search class="h-4 w-4" />
</template>
</n-input>
<n-space :size="12">
<n-button
type="primary"
:size="buttonSize"
:loading="exporting"
@click="exportToCsv"
:block="isMobile"
>
<template #icon>
<i-lucide-download class="h-4 w-4" />
</template>
{{ $t('Export') }}
</n-button>
<n-button
:size="buttonSize"
@click="resetAndFetch"
:loading="initialLoading"
:block="isMobile"
>
<template #icon>
<i-lucide-refresh-cw class="h-4 w-4" />
</template>
</n-button>
</n-space>
</div> </div>
</template> </template>
</Card>
<n-empty
v-if="!initialLoading && orders.length === 0"
:description="$t('No order records to display')"
class="empty-state"
>
<template #icon>
<i-lucide-file-text class="h-12 w-12 text-gray-400" />
</template>
</n-empty>
<n-data-table
v-else
:columns="tableColumns"
:data="orders"
:loading="initialLoading"
:pagination="false"
:scroll-x="isMobile ? 1200 : undefined"
:bordered="false"
:single-line="false"
class="orders-table"
size="medium"
/>
<div v-if="orders.length > 0" class="orders-footer">
<div class="orders-info">
{{ $t('Showing {count} orders, total {total}', { count: orders.length, total: totalCount }) }}
</div>
<n-button
v-if="hasMoreToLoad"
type="primary"
:loading="loadingMore"
:size="buttonSize"
:block="isMobile"
@click="loadMore"
>
{{ $t('Load More') }}
</n-button>
</div>
</n-card>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, reactive, computed, onMounted, getCurrentInstance } from 'vue'; import { ref, reactive, computed, onMounted, onUnmounted, h, getCurrentInstance } from 'vue';
import { Button, Card, Input as FInput, createResource } from 'jingrow-ui'; import { NCard, NInput, NButton, NSpace, NDataTable, NEmpty, NTag } from 'naive-ui';
import StatusIndicator from '../components/StatusIndicator.vue'; import { createResource } from 'jingrow-ui';
import { unparse } from 'papaparse'; import { unparse } from 'papaparse';
export default { const instance = getCurrentInstance();
name: 'BillingOrders', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
components: { const pageSize = 20;
Button, const orders = ref([]);
Card, const totalCount = ref(0);
FInput, const currentPage = ref(1);
StatusIndicator, const initialLoading = ref(true);
}, const loadingMore = ref(false);
setup() { const exporting = ref(false);
const instance = getCurrentInstance(); const windowWidth = ref(window.innerWidth);
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const pageSize = 20; const isMobile = computed(() => windowWidth.value <= 768);
const orders = ref([]); const inputSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const totalCount = ref(0); const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const currentPage = ref(1);
const initialLoading = ref(true); const filters = reactive({
const loadingMore = ref(false); search: ''
});
const filters = reactive({ //
search: '' const hasMoreToLoad = computed(() => {
}); return orders.value.length < totalCount.value;
});
//
const tableColumns = computed(() => {
const statusColumn = {
title: $t('Status'),
key: 'status',
width: isMobile.value ? 100 : 120,
render(row) {
const statusProps = getStatusProps(row.status);
return h(NTag, {
type: statusProps.color === 'green' ? 'success' :
statusProps.color === 'orange' ? 'warning' :
statusProps.color === 'red' ? 'error' : 'info',
size: isMobile.value ? 'small' : 'medium'
}, { default: () => statusProps.label });
}
};
const baseColumns = [
{
title: $t('Time'),
key: 'creation',
width: isMobile.value ? 140 : 160,
ellipsis: { tooltip: true },
render(row) {
return formatDate(row.creation);
}
},
{
title: $t('Title'),
key: 'title',
width: isMobile.value ? 120 : 150,
ellipsis: { tooltip: true },
render(row) {
return row.title || '-';
}
},
{
title: $t('Order ID'),
key: 'order_id',
width: isMobile.value ? 120 : 150,
ellipsis: { tooltip: true },
render(row) {
return row.order_id || '-';
}
},
{
title: $t('Transaction ID'),
key: 'trade_no',
width: isMobile.value ? 120 : 150,
ellipsis: { tooltip: true },
render(row) {
return row.trade_no || '-';
}
},
{
title: $t('Order Type'),
key: 'order_type',
width: isMobile.value ? 100 : 120,
ellipsis: { tooltip: true },
render(row) {
return row.order_type || '-';
}
},
{
title: $t('Payment Method'),
key: 'payment_method',
width: isMobile.value ? 100 : 120,
ellipsis: { tooltip: true },
render(row) {
return row.payment_method || '-';
}
},
{
title: $t('Description'),
key: 'description',
width: isMobile.value ? 150 : 200,
ellipsis: { tooltip: true },
render(row) {
return row.description || '-';
}
},
{
title: $t('Amount'),
key: 'total',
width: isMobile.value ? 100 : 120,
align: 'right',
render(row) {
return formatCurrency(row.total);
}
},
statusColumn
];
//
if (isMobile.value) {
return baseColumns.filter(col =>
['creation', 'title', 'total', 'status'].includes(col.key)
);
}
return baseColumns;
});
// //
const columns = [ const ordersResource = createResource({
{ key: 'creation', label: $t('Time'), class: '' },
{ key: 'title', label: $t('Title'), class: '' },
{ key: 'order_id', label: $t('Order ID'), class: '' },
{ key: 'trade_no', label: $t('Transaction ID'), class: '' },
{ key: 'order_type', label: $t('Order Type'), class: '' },
{ key: 'payment_method', label: $t('Payment Method'), class: '' },
{ key: 'description', label: $t('Description'), class: '' },
{ key: 'total', label: $t('Amount'), class: 'text-right' },
{ key: 'status', label: $t('Status'), class: '' }
];
//
const hasMoreToLoad = computed(() => {
return orders.value.length < totalCount.value;
});
//
const ordersResource = createResource({
url: 'jcloud.api.billing.get_orders', url: 'jcloud.api.billing.get_orders',
transform(response) { transform(response) {
return { return {
@ -165,178 +242,247 @@ export default {
initialLoading.value = false; initialLoading.value = false;
loadingMore.value = false; loadingMore.value = false;
}, },
onError(error) { onError(error) {
initialLoading.value = false; initialLoading.value = false;
loadingMore.value = false; loadingMore.value = false;
} }
}); });
// //
const exportResource = createResource({ const exportResource = createResource({
url: 'jcloud.api.billing.get_orders', url: 'jcloud.api.billing.get_orders',
onSuccess(response) { onSuccess(response) {
const orders = response.orders || []; const orders = response.orders || [];
// CSV
const fields = [
$t('Title'),
$t('Order ID'),
$t('Transaction ID'),
$t('Order Type'),
$t('Payment Method'),
$t('Description'),
$t('Amount'),
$t('Status'),
$t('Creation Time')
];
//
const csvData = orders.map(order => [
order.title || '',
order.order_id || '',
order.trade_no || '',
order.order_type || '',
order.payment_method || '',
order.description || '',
order.total || 0,
order.status || '',
order.creation || ''
]);
//
csvData.unshift(fields);
// CSV
let csv = unparse(csvData);
// BOM
csv = '\uFEFF' + csv;
//
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const today = new Date().toISOString().split('T')[0];
const filename = `${$t('Order Records')}-${today}.csv`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
},
onError(error) {
//
}
});
// // CSV
function fetchOrders() { const fields = [
$t('Title'),
$t('Order ID'),
$t('Transaction ID'),
$t('Order Type'),
$t('Payment Method'),
$t('Description'),
$t('Amount'),
$t('Status'),
$t('Creation Time')
];
//
const csvData = orders.map(order => [
order.title || '',
order.order_id || '',
order.trade_no || '',
order.order_type || '',
order.payment_method || '',
order.description || '',
order.total || 0,
order.status || '',
order.creation || ''
]);
//
csvData.unshift(fields);
// CSV
let csv = unparse(csvData);
// BOM
csv = '\uFEFF' + csv;
//
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const today = new Date().toISOString().split('T')[0];
const filename = `${$t('Order Records')}-${today}.csv`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
exporting.value = false;
},
onError(error) {
exporting.value = false;
}
});
//
function fetchOrders() {
if (currentPage.value === 1) { if (currentPage.value === 1) {
initialLoading.value = true; initialLoading.value = true;
} else { } else {
loadingMore.value = true; loadingMore.value = true;
} }
ordersResource.submit({ ordersResource.submit({
page: currentPage.value, page: currentPage.value,
page_size: pageSize, page_size: pageSize,
search: filters.search, search: filters.search,
}); });
} }
// //
function resetAndFetch() { function resetAndFetch() {
currentPage.value = 1; currentPage.value = 1;
fetchOrders(); fetchOrders();
} }
// //
function loadMore() { function loadMore() {
if (loadingMore.value) return; if (loadingMore.value) return;
currentPage.value += 1; currentPage.value += 1;
loadingMore.value = true; loadingMore.value = true;
fetchOrders(); fetchOrders();
} }
// //
let searchTimeout; let searchTimeout;
function debouncedSearch() { function debouncedSearch() {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
resetAndFetch(); resetAndFetch();
}, 300); }, 300);
} }
// CSV // CSV
function exportToCsv() { function exportToCsv() {
exportResource.submit({ exporting.value = true;
page: 1, exportResource.submit({
page_size: 1000, // page: 1,
search: filters.search, page_size: 1000, //
}); search: filters.search,
} });
}
//
function formatCurrency(amount) { //
if (amount === undefined || amount === null) return '-'; function formatCurrency(amount) {
return new Intl.NumberFormat('zh-CN', { if (amount === undefined || amount === null) return '-';
style: 'currency', return new Intl.NumberFormat('zh-CN', {
currency: 'CNY', style: 'currency',
}).format(amount); currency: 'CNY',
} }).format(amount);
}
//
function formatDate(dateString) { //
if (!dateString) return '-'; function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '-'; const date = new Date(dateString);
if (isNaN(date.getTime())) return '-';
// YYYY-MM-DD HH:MM
const year = date.getFullYear(); // YYYY-MM-DD HH:MM
const month = String(date.getMonth() + 1).padStart(2, '0'); const year = date.getFullYear();
const day = String(date.getDate()).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
} return `${year}-${month}-${day} ${hours}:${minutes}`;
}
//
function getStatusProps(status) { //
const statusMap = { function getStatusProps(status) {
'待支付': { label: $t('Pending Payment'), color: 'orange' }, const statusMap = {
'已支付': { label: $t('Paid'), color: 'green' }, '待支付': { label: $t('Pending Payment'), color: 'orange' },
'交易成功': { label: $t('Transaction Successful'), color: 'green' }, '已支付': { label: $t('Paid'), color: 'green' },
'已取消': { label: $t('Cancelled'), color: 'red' }, '交易成功': { label: $t('Transaction Successful'), color: 'green' },
'已退款': { label: $t('Refunded'), color: 'red' }, '已取消': { label: $t('Cancelled'), color: 'red' },
}; '已退款': { label: $t('Refunded'), color: 'red' },
};
return statusMap[status] || { label: status || '', color: 'blue' };
} return statusMap[status] || { label: status || '', color: 'blue' };
}
//
onMounted(() => { function handleResize() {
fetchOrders(); windowWidth.value = window.innerWidth;
}); }
return { //
columns, onMounted(() => {
orders, handleResize();
totalCount, window.addEventListener('resize', handleResize);
currentPage, fetchOrders();
initialLoading, });
loadingMore,
hasMoreToLoad, onUnmounted(() => {
filters, window.removeEventListener('resize', handleResize);
fetchOrders, });
resetAndFetch, </script>
loadMore,
debouncedSearch, <style scoped>
exportToCsv, .billing-orders-container {
formatCurrency, padding: 24px;
formatDate, max-width: 1400px;
getStatusProps margin: 0 auto;
}; }
.orders-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.orders-header {
display: flex;
flex-direction: column;
gap: 16px;
}
.search-input {
width: 100%;
max-width: 300px;
}
.orders-footer {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid var(--n-border-color);
}
.orders-info {
font-size: 14px;
color: var(--n-text-color-2);
}
.empty-state {
padding: 60px 20px;
}
.orders-table {
margin-top: 16px;
}
@media (min-width: 769px) {
.orders-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
} }
};
</script> .orders-footer {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.search-input {
max-width: 300px;
}
}
@media (max-width: 768px) {
.billing-orders-container {
padding: 16px;
}
.orders-header {
gap: 12px;
}
.search-input {
max-width: 100%;
}
}
</style>

View File

@ -1,19 +1,19 @@
<template> <template>
<div <div class="billing-overview-container">
v-if="team.pg" <div v-if="team.pg" class="billing-overview-content">
class="flex flex-1 flex-col gap-8 overflow-y-auto px-60 pt-6" <PaymentDetails />
> </div>
<PaymentDetails /> <div v-else class="loading-container">
</div> <n-spin size="large" />
<div v-else class="mt-12 flex flex-1 items-center justify-center"> </div>
<Spinner class="h-8" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, provide, inject, onMounted, onUnmounted, ref } from 'vue';
import { NSpin } from 'naive-ui';
import PaymentDetails from '../components/billing/PaymentDetails.vue'; import PaymentDetails from '../components/billing/PaymentDetails.vue';
import { Spinner, createResource } from 'jingrow-ui'; import { createResource } from 'jingrow-ui';
import { computed, provide, inject } from 'vue';
const team = inject('team'); const team = inject('team');
@ -37,4 +37,31 @@ provide('billing', {
), ),
unpaidInvoices unpaidInvoices
}); });
</script> </script>
<style scoped>
.billing-overview-container {
width: 100%;
min-height: 100%;
}
.billing-overview-content {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 24px;
}
@media (max-width: 768px) {
.billing-overview-content {
padding: 16px;
}
}
</style>

View File

@ -1,209 +1,221 @@
<template> <template>
<div class="p-5"> <div class="billing-payment-methods-container">
<ObjectList :options="options" /> <n-card class="payment-methods-card">
<ObjectList :options="options" />
</n-card>
</div> </div>
</template> </template>
<script> <script setup>
import { defineAsyncComponent, h } from 'vue'; import { defineAsyncComponent, h, computed, getCurrentInstance } from 'vue';
import { NCard } from 'naive-ui';
import ObjectList from '../components/ObjectList.vue'; import ObjectList from '../components/ObjectList.vue';
import { Badge, FeatherIcon, Tooltip } from 'jingrow-ui'; import { Badge, FeatherIcon, Tooltip } from 'jingrow-ui';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { confirmDialog, renderDialog, icon } from '../utils/components'; import { confirmDialog, renderDialog, icon } from '../utils/components';
export default { const instance = getCurrentInstance();
name: 'BillingPaymentMethods', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
props: ['tab'], const $team = instance?.appContext.config.globalProperties.$team;
components: {
ObjectList const props = defineProps(['tab']);
},
computed: { const options = computed(() => {
options() { return {
return { pagetype: 'Stripe Payment Method',
pagetype: 'Stripe Payment Method', fields: [
fields: [ 'name',
'name', 'is_default',
'is_default', 'expiry_month',
'expiry_month', 'expiry_year',
'expiry_year', 'brand',
'brand', 'stripe_mandate_id'
'stripe_mandate_id' ],
], emptyStateMessage: $t('No cards added'),
emptyStateMessage: this.$t('No cards added'), columns: [
columns: [ {
{ label: $t('Card Name'),
label: this.$t('Card Name'), fieldname: 'name_on_card'
fieldname: 'name_on_card' },
}, {
{ label: $t('Card'),
label: this.$t('Card'), fieldname: 'last_4',
fieldname: 'last_4', width: 1.5,
width: 1.5, format(value) {
format(value) { return `•••• ${value}`;
return `•••• ${value}`; },
}, prefix: row => {
prefix: row => { return cardBrandIcon(row.brand);
return this.cardBrandIcon(row.brand); },
}, suffix(row) {
suffix(row) { if (row.is_default) {
if (row.is_default) { return h(
return h( Badge,
Badge, {
{ theme: 'green'
theme: 'green' },
}, () => $t('Default')
() => this.$t('Default') );
);
}
}
},
{
label: this.$t('Expiry Date'),
width: 0.5,
format(value, row) {
return `${row.expiry_month}/${row.expiry_year}`;
}
},
{
label: this.$t('Authorization'),
type: 'Component',
width: 1,
align: 'center',
component({ row }) {
if (row.stripe_mandate_id) {
return h(FeatherIcon, {
name: 'check-circle',
class: 'h-4 w-4 text-green-600'
});
}
}
},
{
label: '',
type: 'Component',
align: 'right',
component({ row }) {
if (row.is_default && row.stripe_payment_method) {
return h(
Tooltip,
{
text: this.$t('This card failed to pay last time. Please use another card.')
},
() =>
h(FeatherIcon, {
name: 'alert-circle',
class: 'h-4 w-4 text-red-600'
})
);
}
}
},
{
label: '',
fieldname: 'creation',
type: 'Timestamp',
align: 'right'
} }
], }
rowActions: ({ listResource, row }) => { },
return [ {
{ label: $t('Expiry Date'),
label: this.$t('Set as Default'), width: 0.5,
onClick: () => { format(value, row) {
return `${row.expiry_month}/${row.expiry_year}`;
}
},
{
label: $t('Authorization'),
type: 'Component',
width: 1,
align: 'center',
component({ row }) {
if (row.stripe_mandate_id) {
return h(FeatherIcon, {
name: 'check-circle',
class: 'h-4 w-4 text-green-600'
});
}
}
},
{
label: '',
type: 'Component',
align: 'right',
component({ row }) {
if (row.is_default && row.stripe_payment_method) {
return h(
Tooltip,
{
text: $t('This card failed to pay last time. Please use another card.')
},
() =>
h(FeatherIcon, {
name: 'alert-circle',
class: 'h-4 w-4 text-red-600'
})
);
}
}
},
{
label: '',
fieldname: 'creation',
type: 'Timestamp',
align: 'right'
}
],
rowActions: ({ listResource, row }) => {
return [
{
label: $t('Set as Default'),
onClick: () => {
toast.promise(
listResource.runDocMethod.submit({
method: 'set_default',
name: row.name
}),
{
loading: $t('Setting as default...'),
success: $t('Default card set'),
error: $t('Failed to set default card')
}
);
},
condition: () => !row.is_default
},
{
label: $t('Remove'),
onClick: () => {
if (row.is_default && $team.pg.payment_mode === 'Card') {
toast.error($t('Cannot remove default card'));
return;
}
confirmDialog({
title: $t('Remove Card'),
message: $t('Are you sure you want to remove this card?'),
onSuccess: ({ hide }) => {
toast.promise( toast.promise(
listResource.runDocMethod.submit({ listResource.delete.submit(row.name, {
method: 'set_default', onSuccess() {
name: row.name hide();
}
}), }),
{ {
loading: this.$t('Setting as default...'), loading: $t('Removing card...'),
success: this.$t('Default card set'), success: $t('Card removed'),
error: this.$t('Failed to set default card') error: error =>
error.messages?.length
? error.messages.join('\n')
: error.message || $t('Failed to remove card')
} }
); );
},
condition: () => !row.is_default
},
{
label: this.$t('Remove'),
onClick: () => {
if (row.is_default && this.$team.pg.payment_mode === 'Card') {
toast.error(this.$t('Cannot remove default card'));
return;
}
confirmDialog({
title: this.$t('Remove Card'),
message: this.$t('Are you sure you want to remove this card?'),
onSuccess: ({ hide }) => {
toast.promise(
listResource.delete.submit(row.name, {
onSuccess() {
hide();
}
}),
{
loading: this.$t('Removing card...'),
success: this.$t('Card removed'),
error: error =>
error.messages?.length
? error.messages.join('\n')
: error.message || this.$t('Failed to remove card')
}
);
}
});
} }
} });
]; }
}
];
},
orderBy: 'creation desc',
primaryAction() {
return {
label: $t('Add Card'),
slots: {
prefix: icon('plus')
}, },
orderBy: 'creation desc', onClick: () => {
primaryAction() { let StripeCardDialog = defineAsyncComponent(() =>
return { import('../components/StripeCardDialog.vue')
label: this.$t('Add Card'), );
slots: { renderDialog(StripeCardDialog);
prefix: icon('plus')
},
onClick: () => {
let StripeCardDialog = defineAsyncComponent(() =>
import('../components/StripeCardDialog.vue')
);
renderDialog(StripeCardDialog);
}
};
} }
}; };
} }
}, };
methods: { });
formatCurrency(value) {
if (value === 0) {
return '';
}
return this.$format.userCurrency(value);
},
cardBrandIcon(brand) {
let component = {
'master-card': defineAsyncComponent(() =>
import('@/components/icons/cards/MasterCard.vue')
),
visa: defineAsyncComponent(() =>
import('@/components/icons/cards/Visa.vue')
),
amex: defineAsyncComponent(() =>
import('@/components/icons/cards/Amex.vue')
),
jcb: defineAsyncComponent(() =>
import('@/components/icons/cards/JCB.vue')
),
generic: defineAsyncComponent(() =>
import('@/components/icons/cards/Generic.vue')
),
'union-pay': defineAsyncComponent(() =>
import('@/components/icons/cards/UnionPay.vue')
)
}[brand || 'generic'];
return h(component, { class: 'h-4 w-6' }); function cardBrandIcon(brand) {
} let component = {
'master-card': defineAsyncComponent(() =>
import('@/components/icons/cards/MasterCard.vue')
),
visa: defineAsyncComponent(() =>
import('@/components/icons/cards/Visa.vue')
),
amex: defineAsyncComponent(() =>
import('@/components/icons/cards/Amex.vue')
),
jcb: defineAsyncComponent(() =>
import('@/components/icons/cards/JCB.vue')
),
generic: defineAsyncComponent(() =>
import('@/components/icons/cards/Generic.vue')
),
'union-pay': defineAsyncComponent(() =>
import('@/components/icons/cards/UnionPay.vue')
)
}[brand || 'generic'];
return h(component, { class: 'h-4 w-6' });
}
</script>
<style scoped>
.billing-payment-methods-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.payment-methods-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
@media (max-width: 768px) {
.billing-payment-methods-container {
padding: 16px;
} }
}; }
</script> </style>

View File

@ -106,6 +106,7 @@ Domains,域名,
Download,下载, Download,下载,
Draft,草案, Draft,草案,
Due Date,到期日, Due Date,到期日,
Export,导出,
Export as CSV,导出为CSV, Export as CSV,导出为CSV,
Duration,持续时间, Duration,持续时间,
JERP Partner,JERP合作伙伴, JERP Partner,JERP合作伙伴,

1 API Key API密钥
106 Download 下载
107 Draft 草案
108 Due Date 到期日
109 Export 导出
110 Export as CSV 导出为CSV
111 Duration 持续时间
112 JERP Partner JERP合作伙伴