账单页面及弹窗基于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>
<Dialog v-model="show" :options="{ title: $t('Account Recharge') }">
<template #body-content>
<div
<n-modal
v-model:show="show"
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"
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" />
<span>
{{ $t('Please recharge your account balance before changing payment method.') }}
</span>
</div>
{{ $t('Please recharge your account balance before changing payment method.') }}
</n-alert>
<PrepaidCreditsForm
@success="
() => {
@ -18,12 +28,13 @@
}
"
/>
</template>
</Dialog>
</n-space>
</n-modal>
</template>
<script setup>
import { NModal, NSpace, NAlert } from 'naive-ui';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import PrepaidCreditsForm from '../BuyPrepaidCreditsForm.vue';
import { Dialog, FeatherIcon } from 'jingrow-ui';
const props = defineProps({
showMessage: {
@ -34,4 +45,53 @@ const props = defineProps({
const emit = defineEmits(['success']);
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>
<Dialog v-model="show" :options="{ title: $t('Billing Details') }">
<template #body-content>
<div
<n-modal
v-model:show="show"
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"
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" />
<span> {{ $t('Please add billing details to your account before continuing.') }}</span>
</div>
{{ $t('Please add billing details to your account before continuing.') }}
</n-alert>
<BillingDetails
ref="billingRef"
@success="
@ -17,13 +29,13 @@
}
"
/>
</template>
</Dialog>
</n-space>
</n-modal>
</template>
<script setup>
import { NModal, NSpace, NAlert } from 'naive-ui';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import BillingDetails from './BillingDetails.vue';
import { FeatherIcon, Dialog } from 'jingrow-ui';
import { ref } from 'vue';
const props = defineProps({
showMessage: {
@ -31,7 +43,57 @@ const props = defineProps({
default: false
}
});
const emit = defineEmits(['success']);
const show = defineModel();
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>
<div class="flex flex-col gap-4 py-10">
<div class="flex flex-col">
<div class="flex items-center justify-between text-base text-gray-900">
<div class="flex flex-col gap-1.5">
<div class="text-lg font-semibold text-gray-900">{{ $t('Account Balance') }}</div>
<div class="text-2xl font-bold text-blue-600 py-6">
{{ availableCredits || currency + ' 0.00' }}
<n-space vertical :size="24">
<n-card class="payment-details-card" :title="$t('Account Balance')">
<n-space vertical :size="16">
<div class="balance-section">
<div class="balance-info">
<div class="balance-amount">
{{ availableCredits || currency + ' 0.00' }}
</div>
</div>
</div>
<div class="shrink-0">
<Button
:label="$t('Recharge')"
<n-button
type="primary"
@click="
() => {
showMessage = false;
showAddPrepaidCreditsDialog = true;
}
"
:size="buttonSize"
:block="isMobile"
>
<template #prefix>
<FeatherIcon class="h-4" name="plus" />
<template #icon>
<i-lucide-plus class="h-4 w-4" />
</template>
</Button>
{{ $t('Recharge') }}
</n-button>
</div>
</div>
<div class="my-3 h-px bg-gray-100" />
<div class="flex items-center justify-between text-base text-gray-900">
<div class="flex flex-col gap-1.5">
<div class="font-medium">{{ $t('Billing Address') }}</div>
<div v-if="billingDetailsSummary" class="leading-5 text-gray-700">
{{ billingDetailsSummary }}
</n-space>
</n-card>
<n-card class="payment-details-card" :title="$t('Billing Address')">
<n-space vertical :size="16">
<div class="address-section">
<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 v-else class="text-gray-700">{{ $t('No address') }}</div>
</div>
<div class="shrink-0">
<Button
:label="billingAddressButtonLabel"
<n-button
:type="billingDetailsSummary ? 'default' : 'primary'"
@click="
() => {
showMessage = false;
showBillingDetailsDialog = true;
}
"
:size="buttonSize"
:block="isMobile"
>
<template v-if="!billingDetailsSummary" #prefix>
<FeatherIcon class="h-4" name="plus" />
<template v-if="!billingDetailsSummary" #icon>
<i-lucide-plus class="h-4 w-4" />
</template>
</Button>
{{ billingAddressButtonLabel }}
</n-button>
</div>
</div>
</div>
</div>
</n-space>
</n-card>
</n-space>
<BillingDetailsDialog
v-if="showBillingDetailsDialog"
v-model="showBillingDetailsDialog"
@ -65,16 +74,11 @@
/>
</template>
<script setup>
import DropdownItem from './DropdownItem.vue';
import { NCard, NSpace, NButton } from 'naive-ui';
import BillingDetailsDialog from './BillingDetailsDialog.vue';
import AddPrepaidCreditsDialog from './AddPrepaidCreditsDialog.vue';
import { Dropdown, Button, FeatherIcon, createResource } from 'jingrow-ui';
import {
confirmDialog,
renderDialog,
} from '../../utils/components';
import { computed, ref, inject, h, defineAsyncComponent, onMounted, nextTick, getCurrentInstance } from 'vue';
import router from '../../router';
import { createResource } from 'jingrow-ui';
import { computed, ref, inject, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
//
const countryToZh = {
@ -96,6 +100,10 @@ const {
const showBillingDetailsDialog = 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' ? '¥' : '$'));
@ -193,8 +201,14 @@ const billingAddressButtonLabel = computed(() => {
return billingDetailsSummary.value ? $t('Edit') : $t('Add Billing Address');
});
function handleResize() {
windowWidth.value = window.innerWidth;
}
// onMounted
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
//
nextTick(() => {
if (!team.pg.payment_mode) {
@ -203,6 +217,10 @@ onMounted(() => {
});
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
function payUnpaidInvoices() {
let _unpaidInvoices = unpaidInvoices.data;
if (_unpaidInvoices.length > 1) {
@ -231,4 +249,63 @@ function updatePaymentMode(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>
<div class="sticky top-0 z-10 shrink-0">
<div class="billing-container">
<Header>
<FBreadcrumbs
:items="[{ label: $t('Billing'), route: { name: 'Billing' } }]"
/>
<n-breadcrumb>
<n-breadcrumb-item>
<router-link :to="{ name: 'Billing' }">
{{ $t('Billing') }}
</router-link>
</n-breadcrumb-item>
</n-breadcrumb>
</Header>
<TabsWithRouter
v-if="$team?.pg?.is_desk_user || $session.hasBillingAccess"
:tabs="filteredTabs"
/>
<div
v-else
class="mx-auto mt-60 w-fit rounded border border-dashed px-12 py-8 text-center text-gray-600"
>
<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')" />
<div v-if="$team?.pg?.is_desk_user || $session.hasBillingAccess" class="billing-content">
<n-tabs
v-model:value="activeTab"
type="line"
animated
:bar-width="isMobile ? 0 : undefined"
:size="isMobile ? 'medium' : 'large'"
class="billing-tabs"
>
<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>
</template>
<script>
import { Tabs, Breadcrumbs } from 'jingrow-ui';
<script setup>
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 TabsWithRouter from '../components/TabsWithRouter.vue';
export default {
name: 'Billing',
components: {
Header,
FBreadcrumbs: Breadcrumbs,
FTabs: Tabs,
TabsWithRouter,
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const $team = instance?.appContext.config.globalProperties.$team;
const $session = instance?.appContext.config.globalProperties.$session;
const route = useRoute();
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,
tabs: [
{ label: this.$t('Overview'), route: { name: 'BillingOverview' } },
{ label: this.$t('Order Records'), route: { name: 'BillingOrders' } },
{ label: this.$t('Balance Details'), route: { name: 'BillingBalances' } },
{
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;
});
];
const filteredTabs = computed(() => {
return tabs.filter(tab => {
//
if (tab.requireDeveloper && !$team.pg.is_developer) {
return false;
}
//
if (tab.requirePro && !$team.pg.is_pro) {
return false;
}
return true;
});
});
const activeTab = computed({
get() {
return route.name || 'BillingOverview';
},
};
</script>
set(value) {
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>
<div class="p-5">
<ObjectList :options="options">
<template #header-right>
<Button
icon="download"
appearance="primary"
@click="exportToCsv"
:loading="exporting"
>
{{ $t('Export') }}
</Button>
</template>
</ObjectList>
<div class="billing-balances-container">
<n-card class="balances-card">
<ObjectList :options="options">
<template #header-right>
<n-button
type="primary"
:size="buttonSize"
:loading="exporting"
:block="isMobile"
@click="exportToCsv"
>
<template #icon>
<i-lucide-download class="h-4 w-4" />
</template>
{{ $t('Export') }}
</n-button>
</template>
</ObjectList>
</n-card>
</div>
</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 { Button, createResource } from 'jingrow-ui';
import { createResource } from 'jingrow-ui';
import { unparse } from 'papaparse';
export default {
name: 'BillingBalances',
props: ['tab'],
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 });
}
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const $team = instance?.appContext.config.globalProperties.$team;
if (row.source === 'Prepaid Credits') {
return this.$t('Balance Recharge');
}
const exporting = ref(false);
const windowWidth = ref(window.innerWidth);
if (row.source === 'Free Credits') {
return this.$t('Free Credits');
}
const isMobile = computed(() => windowWidth.value <= 768);
const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
return row.amount < 0 ? row.type : row.source;
}
},
{
label: this.$t('Amount'),
fieldname: 'amount',
align: 'right',
format: this.formatCurrency
},
{
label: this.$t('Balance'),
fieldname: 'ending_balance',
align: 'right',
format: this.formatCurrency
}
],
filters: {
pagestatus: 1,
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;
}
const props = defineProps(['tab']);
const options = computed(() => {
return {
pagetype: 'Balance Transaction',
fields: ['type', 'source', 'invoice', 'description'],
columns: [
{
label: $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, '-');
}
return [
this.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 = `${this.$t('Balance Records')}-${today}.csv`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
this.exporting = false;
},
{
label: $t('Description'),
fieldname: 'description',
format(value, row) {
if (value !== null && value !== undefined) {
return value;
}
if (row.type === 'Applied To Invoice' && row.invoice) {
return $t('Applied to Invoice {invoice}', { invoice: row.invoice });
}
if (row.source === 'Prepaid Credits') {
return $t('Balance Recharge');
}
if (row.source === 'Free Credits') {
return $t('Free Credits');
}
return row.amount < 0 ? row.type : row.source;
}
},
{
label: $t('Amount'),
fieldname: 'amount',
align: 'right',
format: formatCurrency
},
{
label: $t('Balance'),
fieldname: 'ending_balance',
align: 'right',
format: formatCurrency
}
],
filters: {
pagestatus: 1,
team: $team.name
},
handleExportError(error) {
console.error(this.$t('Failed to export data'), error);
this.exporting = false;
}
orderBy: 'creation desc'
};
});
//
const exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: handleExportSuccess,
onError: handleExportError
});
function formatCurrency(value) {
if (value === 0) {
return '';
}
};
</script>
<style scoped>
/* 添加悬浮效果 */
.flex-col:hover {
background-color: #f0f0f0; /* 可按需调整颜色 */
const $format = instance?.appContext.config.globalProperties.$format;
return $format?.userCurrency(value) || value;
}
/* 允许内容复制 */
.flex-col * {
user-select: text;
function 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, '-');
}
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>

View File

@ -1,105 +1,171 @@
<template>
<div class="p-5">
<ObjectList :options="options" />
<Dialog
v-model="payoutDialog"
:options="{ size: '6xl', title: showPayout?.name }"
<div class="billing-marketplace-payouts-container">
<n-card class="payouts-card">
<ObjectList :options="options" />
</n-card>
<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 v-if="showPayout">
<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 #header>
<span class="text-lg font-semibold">{{ showPayout?.name }}</span>
</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>
</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 PayoutTable from '../components/PayoutTable.vue';
export default {
name: 'BillingMarketplacePayouts',
props: ['tab'],
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;
}
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const $team = instance?.appContext.config.globalProperties.$team;
return this.$format.userCurrency(total);
}
}
],
onRowClick: row => {
this.showPayout = row;
this.payoutDialog = true;
const props = defineProps(['tab']);
const payoutDialog = ref(false);
const showPayout = ref(null);
const windowWidth = ref(window.innerWidth);
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>
<div class="p-4">
<div class="mb-4 flex justify-between">
<div>
<FInput
v-model="filters.search"
:placeholder="$t('Search orders...')"
type="text"
class="w-60"
@input="debouncedSearch"
>
<template #prefix>
<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"
<div class="billing-orders-container">
<n-card class="orders-card">
<template #header>
<div class="orders-header">
<n-input
v-model:value="filters.search"
:placeholder="$t('Search orders...')"
clearable
:size="inputSize"
class="search-input"
@input="debouncedSearch"
>
{{ $t('Load More') }}
</Button>
<template #prefix>
<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>
</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>
</template>
<script>
import { ref, reactive, computed, onMounted, getCurrentInstance } from 'vue';
import { Button, Card, Input as FInput, createResource } from 'jingrow-ui';
import StatusIndicator from '../components/StatusIndicator.vue';
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, h, getCurrentInstance } from 'vue';
import { NCard, NInput, NButton, NSpace, NDataTable, NEmpty, NTag } from 'naive-ui';
import { createResource } from 'jingrow-ui';
import { unparse } from 'papaparse';
export default {
name: 'BillingOrders',
components: {
Button,
Card,
FInput,
StatusIndicator,
},
setup() {
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const pageSize = 20;
const orders = ref([]);
const totalCount = ref(0);
const currentPage = ref(1);
const initialLoading = ref(true);
const loadingMore = ref(false);
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const pageSize = 20;
const orders = ref([]);
const totalCount = ref(0);
const currentPage = ref(1);
const initialLoading = ref(true);
const loadingMore = ref(false);
const exporting = ref(false);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const inputSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const filters = reactive({
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 = [
{ 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({
//
const ordersResource = createResource({
url: 'jcloud.api.billing.get_orders',
transform(response) {
return {
@ -165,178 +242,247 @@ export default {
initialLoading.value = false;
loadingMore.value = false;
},
onError(error) {
initialLoading.value = false;
loadingMore.value = false;
}
});
//
const exportResource = createResource({
onError(error) {
initialLoading.value = false;
loadingMore.value = false;
}
});
//
const exportResource = createResource({
url: 'jcloud.api.billing.get_orders',
onSuccess(response) {
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) {
//
}
});
onSuccess(response) {
const orders = response.orders || [];
//
function fetchOrders() {
// 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);
exporting.value = false;
},
onError(error) {
exporting.value = false;
}
});
//
function fetchOrders() {
if (currentPage.value === 1) {
initialLoading.value = true;
} else {
loadingMore.value = true;
}
ordersResource.submit({
page: currentPage.value,
page_size: pageSize,
search: filters.search,
});
}
//
function resetAndFetch() {
currentPage.value = 1;
fetchOrders();
}
//
function loadMore() {
if (loadingMore.value) return;
currentPage.value += 1;
loadingMore.value = true;
fetchOrders();
}
//
let searchTimeout;
function debouncedSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
resetAndFetch();
}, 300);
}
// CSV
function exportToCsv() {
exportResource.submit({
page: 1,
page_size: 1000, //
search: filters.search,
});
}
//
function formatCurrency(amount) {
if (amount === undefined || amount === null) return '-';
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(amount);
}
//
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '-';
// YYYY-MM-DD HH:MM
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).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}`;
}
//
function getStatusProps(status) {
const statusMap = {
'待支付': { label: $t('Pending Payment'), color: 'orange' },
'已支付': { label: $t('Paid'), color: 'green' },
'交易成功': { label: $t('Transaction Successful'), color: 'green' },
'已取消': { label: $t('Cancelled'), color: 'red' },
'已退款': { label: $t('Refunded'), color: 'red' },
};
return statusMap[status] || { label: status || '', color: 'blue' };
}
//
onMounted(() => {
fetchOrders();
});
return {
columns,
orders,
totalCount,
currentPage,
initialLoading,
loadingMore,
hasMoreToLoad,
filters,
fetchOrders,
resetAndFetch,
loadMore,
debouncedSearch,
exportToCsv,
formatCurrency,
formatDate,
getStatusProps
};
ordersResource.submit({
page: currentPage.value,
page_size: pageSize,
search: filters.search,
});
}
//
function resetAndFetch() {
currentPage.value = 1;
fetchOrders();
}
//
function loadMore() {
if (loadingMore.value) return;
currentPage.value += 1;
loadingMore.value = true;
fetchOrders();
}
//
let searchTimeout;
function debouncedSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
resetAndFetch();
}, 300);
}
// CSV
function exportToCsv() {
exporting.value = true;
exportResource.submit({
page: 1,
page_size: 1000, //
search: filters.search,
});
}
//
function formatCurrency(amount) {
if (amount === undefined || amount === null) return '-';
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(amount);
}
//
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '-';
// YYYY-MM-DD HH:MM
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).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}`;
}
//
function getStatusProps(status) {
const statusMap = {
'待支付': { label: $t('Pending Payment'), color: 'orange' },
'已支付': { label: $t('Paid'), color: 'green' },
'交易成功': { label: $t('Transaction Successful'), color: 'green' },
'已取消': { label: $t('Cancelled'), color: 'red' },
'已退款': { label: $t('Refunded'), color: 'red' },
};
return statusMap[status] || { label: status || '', color: 'blue' };
}
function handleResize() {
windowWidth.value = window.innerWidth;
}
//
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
fetchOrders();
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.billing-orders-container {
padding: 24px;
max-width: 1400px;
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>
<div
v-if="team.pg"
class="flex flex-1 flex-col gap-8 overflow-y-auto px-60 pt-6"
>
<PaymentDetails />
</div>
<div v-else class="mt-12 flex flex-1 items-center justify-center">
<Spinner class="h-8" />
<div class="billing-overview-container">
<div v-if="team.pg" class="billing-overview-content">
<PaymentDetails />
</div>
<div v-else class="loading-container">
<n-spin size="large" />
</div>
</div>
</template>
<script setup>
import { computed, provide, inject, onMounted, onUnmounted, ref } from 'vue';
import { NSpin } from 'naive-ui';
import PaymentDetails from '../components/billing/PaymentDetails.vue';
import { Spinner, createResource } from 'jingrow-ui';
import { computed, provide, inject } from 'vue';
import { createResource } from 'jingrow-ui';
const team = inject('team');
@ -37,4 +37,31 @@ provide('billing', {
),
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>
<div class="p-5">
<ObjectList :options="options" />
<div class="billing-payment-methods-container">
<n-card class="payment-methods-card">
<ObjectList :options="options" />
</n-card>
</div>
</template>
<script>
import { defineAsyncComponent, h } from 'vue';
<script setup>
import { defineAsyncComponent, h, computed, getCurrentInstance } from 'vue';
import { NCard } from 'naive-ui';
import ObjectList from '../components/ObjectList.vue';
import { Badge, FeatherIcon, Tooltip } from 'jingrow-ui';
import { toast } from 'vue-sonner';
import { confirmDialog, renderDialog, icon } from '../utils/components';
export default {
name: 'BillingPaymentMethods',
props: ['tab'],
components: {
ObjectList
},
computed: {
options() {
return {
pagetype: 'Stripe Payment Method',
fields: [
'name',
'is_default',
'expiry_month',
'expiry_year',
'brand',
'stripe_mandate_id'
],
emptyStateMessage: this.$t('No cards added'),
columns: [
{
label: this.$t('Card Name'),
fieldname: 'name_on_card'
},
{
label: this.$t('Card'),
fieldname: 'last_4',
width: 1.5,
format(value) {
return `•••• ${value}`;
},
prefix: row => {
return this.cardBrandIcon(row.brand);
},
suffix(row) {
if (row.is_default) {
return h(
Badge,
{
theme: 'green'
},
() => 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'
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const $team = instance?.appContext.config.globalProperties.$team;
const props = defineProps(['tab']);
const options = computed(() => {
return {
pagetype: 'Stripe Payment Method',
fields: [
'name',
'is_default',
'expiry_month',
'expiry_year',
'brand',
'stripe_mandate_id'
],
emptyStateMessage: $t('No cards added'),
columns: [
{
label: $t('Card Name'),
fieldname: 'name_on_card'
},
{
label: $t('Card'),
fieldname: 'last_4',
width: 1.5,
format(value) {
return `•••• ${value}`;
},
prefix: row => {
return cardBrandIcon(row.brand);
},
suffix(row) {
if (row.is_default) {
return h(
Badge,
{
theme: 'green'
},
() => $t('Default')
);
}
],
rowActions: ({ listResource, row }) => {
return [
{
label: this.$t('Set as Default'),
onClick: () => {
}
},
{
label: $t('Expiry Date'),
width: 0.5,
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(
listResource.runDocMethod.submit({
method: 'set_default',
name: row.name
listResource.delete.submit(row.name, {
onSuccess() {
hide();
}
}),
{
loading: this.$t('Setting as default...'),
success: this.$t('Default card set'),
error: this.$t('Failed to set default card')
loading: $t('Removing card...'),
success: $t('Card removed'),
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',
primaryAction() {
return {
label: this.$t('Add Card'),
slots: {
prefix: icon('plus')
},
onClick: () => {
let StripeCardDialog = defineAsyncComponent(() =>
import('../components/StripeCardDialog.vue')
);
renderDialog(StripeCardDialog);
}
};
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,下载,
Draft,草案,
Due Date,到期日,
Export,导出,
Export as CSV,导出为CSV,
Duration,持续时间,
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合作伙伴