账单页面及弹窗基于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"
v-if="showMessage" :title="$t('Account Recharge')"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700" :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"
type="info"
:title="$t('Information')"
closable
> >
<FeatherIcon class="h-4" name="info" />
<span>
{{ $t('Please recharge your account balance before changing payment method.') }} {{ $t('Please recharge your account balance before changing payment method.') }}
</span> </n-alert>
</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();
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> </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"
v-if="showMessage" :title="$t('Billing Details')"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700" :style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="billing-details-modal"
> >
<FeatherIcon class="h-4" name="info" /> <template #header>
<span> {{ $t('Please add billing details to your account before continuing.') }}</span> <span class="text-lg font-semibold">{{ $t('Billing Details') }}</span>
</div> </template>
<n-space vertical :size="20">
<n-alert
v-if="showMessage"
type="info"
:title="$t('Information')"
closable
>
{{ $t('Please add billing details to your account before continuing.') }}
</n-alert>
<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);
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> </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>
<div class="shrink-0"> <n-button
<Button type="primary"
: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">
<div class="address-info">
<div v-if="billingDetailsSummary" class="address-text">
{{ billingDetailsSummary }} {{ billingDetailsSummary }}
</div> </div>
<div v-else class="text-gray-700">{{ $t('No address') }}</div> <div v-else class="address-empty">
{{ $t('No address') }}
</div> </div>
<div class="shrink-0"> </div>
<Button <n-button
:label="billingAddressButtonLabel" :type="billingDetailsSummary ? 'default' : 'primary'"
@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 }}
</div> </n-button>
</div>
</div>
</div> </div>
</n-space>
</n-card>
</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) {
@ -232,3 +250,62 @@ 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" /> <n-tab-pane
<ErrorMessage :message="$t('You do not have permission to view the billing page')" /> 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();
},
data() { const windowWidth = ref(window.innerWidth);
return { const isMobile = computed(() => windowWidth.value <= 768);
currentTab: 0,
tabs: [ const tabs = [
{ label: this.$t('Overview'), route: { name: 'BillingOverview' } }, { label: $t('Overview'), route: { name: 'BillingOverview' } },
{ label: this.$t('Order Records'), route: { name: 'BillingOrders' } }, { label: $t('Order Records'), route: { name: 'BillingOrders' } },
{ label: this.$t('Balance Details'), route: { name: 'BillingBalances' } }, { label: $t('Balance Details'), route: { name: 'BillingBalances' } },
{ {
label: this.$t('Developer Earnings'), label: $t('Developer Earnings'),
route: { name: 'BillingMarketplacePayouts' }, route: { name: 'BillingMarketplacePayouts' },
requireDeveloper: true, requireDeveloper: true,
requirePro: true requirePro: true
}, },
], ];
};
}, const filteredTabs = computed(() => {
computed: { return tabs.filter(tab => {
filteredTabs() {
return this.tabs.filter(tab => {
// //
if (tab.requireDeveloper && !this.$team.pg.is_developer) { if (tab.requireDeveloper && !$team.pg.is_developer) {
return false; return false;
} }
// //
if (tab.requirePro && !this.$team.pg.is_pro) { if (tab.requirePro && !$team.pg.is_pro) {
return false; return false;
} }
return true; return true;
}); });
} });
const activeTab = computed({
get() {
return route.name || 'BillingOverview';
}, },
}; set(value) {
router.push({ name: value });
}
});
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script> </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,44 +1,50 @@
<template> <template>
<div class="p-5"> <div class="billing-balances-container">
<n-card class="balances-card">
<ObjectList :options="options"> <ObjectList :options="options">
<template #header-right> <template #header-right>
<Button <n-button
icon="download" type="primary"
appearance="primary" :size="buttonSize"
@click="exportToCsv"
:loading="exporting" :loading="exporting"
:block="isMobile"
@click="exportToCsv"
> >
<template #icon>
<i-lucide-download class="h-4 w-4" />
</template>
{{ $t('Export') }} {{ $t('Export') }}
</Button> </n-button>
</template> </template>
</ObjectList> </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, const exporting = ref(false);
Button const windowWidth = ref(window.innerWidth);
},
data() { const isMobile = computed(() => windowWidth.value <= 768);
return { const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
exporting: false
}; const props = defineProps(['tab']);
}, const options = computed(() => {
computed: {
options() {
return { return {
pagetype: 'Balance Transaction', pagetype: 'Balance Transaction',
fields: ['type', 'source', 'invoice', 'description'], fields: ['type', 'source', 'invoice', 'description'],
columns: [ columns: [
{ {
label: this.$t('Time'), label: $t('Time'),
fieldname: 'creation', fieldname: 'creation',
format(value) { format(value) {
return new Date(value).toLocaleString('zh-CN', { return new Date(value).toLocaleString('zh-CN', {
@ -52,7 +58,7 @@ export default {
} }
}, },
{ {
label: this.$t('Description'), label: $t('Description'),
fieldname: 'description', fieldname: 'description',
format(value, row) { format(value, row) {
if (value !== null && value !== undefined) { if (value !== null && value !== undefined) {
@ -60,57 +66,57 @@ export default {
} }
if (row.type === 'Applied To Invoice' && row.invoice) { if (row.type === 'Applied To Invoice' && row.invoice) {
return this.$t('Applied to Invoice {invoice}', { invoice: row.invoice }); return $t('Applied to Invoice {invoice}', { invoice: row.invoice });
} }
if (row.source === 'Prepaid Credits') { if (row.source === 'Prepaid Credits') {
return this.$t('Balance Recharge'); return $t('Balance Recharge');
} }
if (row.source === 'Free Credits') { if (row.source === 'Free Credits') {
return this.$t('Free Credits'); return $t('Free Credits');
} }
return row.amount < 0 ? row.type : row.source; return row.amount < 0 ? row.type : row.source;
} }
}, },
{ {
label: this.$t('Amount'), label: $t('Amount'),
fieldname: 'amount', fieldname: 'amount',
align: 'right', align: 'right',
format: this.formatCurrency format: formatCurrency
}, },
{ {
label: this.$t('Balance'), label: $t('Balance'),
fieldname: 'ending_balance', fieldname: 'ending_balance',
align: 'right', align: 'right',
format: this.formatCurrency format: formatCurrency
} }
], ],
filters: { filters: {
pagestatus: 1, pagestatus: 1,
team: this.$team.name team: $team.name
}, },
orderBy: 'creation desc' orderBy: 'creation desc'
}; };
}
},
created() {
//
this.exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: this.handleExportSuccess,
onError: this.handleExportError
}); });
},
methods: { //
formatCurrency(value) { const exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: handleExportSuccess,
onError: handleExportError
});
function formatCurrency(value) {
if (value === 0) { if (value === 0) {
return ''; return '';
} }
return this.$format.userCurrency(value); const $format = instance?.appContext.config.globalProperties.$format;
}, return $format?.userCurrency(value) || value;
formatDate(dateString) { }
function formatDate(dateString) {
return new Date(dateString).toLocaleString('zh-CN', { return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
@ -119,23 +125,25 @@ export default {
minute: '2-digit', minute: '2-digit',
second: '2-digit' second: '2-digit'
}).replace(/\//g, '-'); }).replace(/\//g, '-');
}, }
exportToCsv() {
this.exporting = true; function exportToCsv() {
this.exportResource.submit({ exporting.value = true;
exportResource.submit({
page: 1, page: 1,
page_size: 1000 page_size: 1000
}); });
}, }
handleExportSuccess(response) {
function handleExportSuccess(response) {
const transactions = response.transactions || []; const transactions = response.transactions || [];
// CSV // CSV
const fields = [ const fields = [
this.$t('Time'), $t('Time'),
this.$t('Description'), $t('Description'),
this.$t('Amount'), $t('Amount'),
this.$t('Balance') $t('Balance')
]; ];
// //
@ -146,18 +154,18 @@ export default {
// 使 // 使
if (!description) { if (!description) {
if (row.type === 'Applied To Invoice' && row.invoice) { if (row.type === 'Applied To Invoice' && row.invoice) {
description = this.$t('Applied to Invoice {invoice}', { invoice: row.invoice }); description = $t('Applied to Invoice {invoice}', { invoice: row.invoice });
} else if (row.source === 'Prepaid Credits') { } else if (row.source === 'Prepaid Credits') {
description = this.$t('Balance Recharge'); description = $t('Balance Recharge');
} else if (row.source === 'Free Credits') { } else if (row.source === 'Free Credits') {
description = this.$t('Free Credits'); description = $t('Free Credits');
} else { } else {
description = row.amount < 0 ? row.type : row.source; description = row.amount < 0 ? row.type : row.source;
} }
} }
return [ return [
this.formatDate(row.creation), formatDate(row.creation),
description, description,
row.amount, row.amount,
row.ending_balance row.ending_balance
@ -176,30 +184,50 @@ export default {
// //
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const filename = `${this.$t('Balance Records')}-${today}.csv`; const filename = `${$t('Balance Records')}-${today}.csv`;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob);
link.download = filename; link.download = filename;
link.click(); link.click();
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
this.exporting = false; exporting.value = false;
},
handleExportError(error) {
console.error(this.$t('Failed to export data'), error);
this.exporting = false;
}
}
};
</script>
<style scoped>
/* 添加悬浮效果 */
.flex-col:hover {
background-color: #f0f0f0; /* 可按需调整颜色 */
} }
/* 允许内容复制 */ function handleExportError(error) {
.flex-col * { console.error($t('Failed to export data'), error);
user-select: text; 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,43 +1,53 @@
<template> <template>
<div class="p-5"> <div class="billing-marketplace-payouts-container">
<n-card class="payouts-card">
<ObjectList :options="options" /> <ObjectList :options="options" />
<Dialog </n-card>
v-model="payoutDialog" <n-modal
:options="{ size: '6xl', title: showPayout?.name }" 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>
<span class="text-lg font-semibold">{{ showPayout?.name }}</span>
</template>
<template v-if="showPayout"> <template v-if="showPayout">
<div <n-empty
v-if="showPayout.status === 'Empty'" v-if="showPayout.status === 'Empty'"
class="text-base text-gray-600" :description="$t('No content to display')"
> />
{{ $t('No content to display') }}
</div>
<PayoutTable v-else :payoutId="showPayout.name" /> <PayoutTable v-else :payoutId="showPayout.name" />
</template> </template>
</template> </n-modal>
</Dialog>
</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 { const props = defineProps(['tab']);
payoutDialog: false,
showPayout: null const payoutDialog = ref(false);
}; const showPayout = ref(null);
}, const windowWidth = ref(window.innerWidth);
components: {
ObjectList, const isMobile = computed(() => windowWidth.value <= 768);
PayoutTable const modalStyle = computed(() => ({
}, width: isMobile.value ? '95vw' : '1200px',
computed: { maxWidth: isMobile.value ? '95vw' : '90vw',
options() { }));
const options = computed(() => {
return { return {
pagetype: 'Payout Order', pagetype: 'Payout Order',
fields: [ fields: [
@ -51,14 +61,14 @@ export default {
return [ return [
{ {
type: 'select', type: 'select',
label: this.$t('Status'), label: $t('Status'),
class: !this.$isMobile ? 'w-36' : '', class: !isMobile.value ? 'w-36' : '',
fieldname: 'status', fieldname: 'status',
options: [ options: [
{ label: '', value: '' }, { label: '', value: '' },
{ label: this.$t('Pending Settlement'), value: 'Draft' }, { label: $t('Pending Settlement'), value: 'Draft' },
{ label: this.$t('Paid'), value: 'Paid' }, { label: $t('Paid'), value: 'Paid' },
{ label: this.$t('Settled'), value: 'Commissioned' } { label: $t('Settled'), value: 'Commissioned' }
] ]
} }
]; ];
@ -66,7 +76,7 @@ export default {
orderBy: 'creation desc', orderBy: 'creation desc',
columns: [ columns: [
{ {
label: this.$t('Date'), label: $t('Date'),
fieldname: 'period_end', fieldname: 'period_end',
format(value) { format(value) {
return Intl.DateTimeFormat('en-US', { return Intl.DateTimeFormat('en-US', {
@ -76,30 +86,86 @@ export default {
}).format(new Date(value)); }).format(new Date(value));
} }
}, },
{ label: this.$t('Payment Method'), fieldname: 'mode_of_payment' }, { label: $t('Payment Method'), fieldname: 'mode_of_payment' },
{ label: this.$t('Status'), fieldname: 'status', type: 'Badge' }, { label: $t('Status'), fieldname: 'status', type: 'Badge' },
{ {
label: this.$t('Total'), label: $t('Total'),
fieldname: 'net_total_cny', fieldname: 'net_total_cny',
align: 'right', align: 'right',
format: (_, row) => { format: (_, row) => {
let total = 0; let total = 0;
if (this.$team.pg.currency === 'CNY') { const $format = instance?.appContext.config.globalProperties.$format;
if ($team.pg.currency === 'CNY') {
total = row.net_total_cny + row.net_total_usd * 82; total = row.net_total_cny + row.net_total_usd * 82;
} else { } else {
total = row.net_total_cny / 82 + row.net_total_usd; total = row.net_total_cny / 82 + row.net_total_usd;
} }
return this.$format.userCurrency(total); return $format?.userCurrency(total) || total;
} }
} }
], ],
onRowClick: row => { onRowClick: row => {
this.showPayout = row; showPayout.value = row;
this.payoutDialog = true; payoutDialog.value = true;
} }
}; };
});
function handleResize() {
windowWidth.value = window.innerWidth;
} }
}
}; onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script> </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;
}
: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,114 +1,95 @@
<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
v-model:value="filters.search"
:placeholder="$t('Search orders...')" :placeholder="$t('Search orders...')"
type="text" clearable
class="w-60" :size="inputSize"
class="search-input"
@input="debouncedSearch" @input="debouncedSearch"
> >
<template #prefix> <template #prefix>
<i-lucide-search class="h-4 w-4 text-gray-500" /> <i-lucide-search class="h-4 w-4" />
</template> </template>
</FInput> </n-input>
</div> <n-space :size="12">
<div class="flex items-center space-x-2"> <n-button
<Button type="primary"
icon="download" :size="buttonSize"
appearance="primary" :loading="exporting"
@click="exportToCsv" @click="exportToCsv"
:block="isMobile"
> >
<template #icon>
<i-lucide-download class="h-4 w-4" />
</template>
{{ $t('Export') }} {{ $t('Export') }}
</Button> </n-button>
<Button <n-button
icon="refresh-cw" :size="buttonSize"
appearance="minimal"
@click="resetAndFetch" @click="resetAndFetch"
/> :loading="initialLoading"
</div> :block="isMobile"
</div> >
<template #icon>
<Card class="overflow-hidden !rounded-none !border-0"> <i-lucide-refresh-cw class="h-4 w-4" />
<template v-if="orders.length === 0"> </template>
<div class="flex h-60 flex-col items-center justify-center space-y-2 p-4 text-center"> </n-button>
<i-lucide-file-text class="h-8 w-8 text-gray-400" /> </n-space>
<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> </div>
</template> </template>
<template v-else> <n-empty
<div class="overflow-x-auto"> v-if="!initialLoading && orders.length === 0"
<table class="w-full"> :description="$t('No order records to display')"
<thead> class="empty-state"
<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 }} <template #icon>
</th> <i-lucide-file-text class="h-12 w-12 text-gray-400" />
</tr> </template>
</thead> </n-empty>
<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"> <n-data-table
<div class="text-sm text-gray-600"> 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 }) }} {{ $t('Showing {count} orders, total {total}', { count: orders.length, total: totalCount }) }}
</div> </div>
<Button <n-button
v-if="hasMoreToLoad" v-if="hasMoreToLoad"
@click="loadMore" type="primary"
:loading="loadingMore" :loading="loadingMore"
appearance="primary" :size="buttonSize"
:block="isMobile"
@click="loadMore"
> >
{{ $t('Load More') }} {{ $t('Load More') }}
</Button> </n-button>
</div> </div>
</template> </n-card>
</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 {
name: 'BillingOrders',
components: {
Button,
Card,
FInput,
StatusIndicator,
},
setup() {
const instance = getCurrentInstance(); const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key); const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const pageSize = 20; const pageSize = 20;
@ -117,29 +98,125 @@ export default {
const currentPage = ref(1); const currentPage = ref(1);
const initialLoading = ref(true); const initialLoading = ref(true);
const loadingMore = ref(false); 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({ const filters = reactive({
search: '' search: ''
}); });
//
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(() => { const hasMoreToLoad = computed(() => {
return orders.value.length < totalCount.value; 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 ordersResource = createResource({ const ordersResource = createResource({
url: 'jcloud.api.billing.get_orders', url: 'jcloud.api.billing.get_orders',
@ -221,9 +298,10 @@ export default {
link.download = filename; link.download = filename;
link.click(); link.click();
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
exporting.value = false;
}, },
onError(error) { onError(error) {
// exporting.value = false;
} }
}); });
@ -268,6 +346,7 @@ export default {
// CSV // CSV
function exportToCsv() { function exportToCsv() {
exporting.value = true;
exportResource.submit({ exportResource.submit({
page: 1, page: 1,
page_size: 1000, // page_size: 1000, //
@ -314,29 +393,96 @@ export default {
return statusMap[status] || { label: status || '', color: 'blue' }; return statusMap[status] || { label: status || '', color: 'blue' };
} }
function handleResize() {
windowWidth.value = window.innerWidth;
}
// //
onMounted(() => { onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
fetchOrders(); fetchOrders();
}); });
return { onUnmounted(() => {
columns, window.removeEventListener('resize', handleResize);
orders, });
totalCount,
currentPage,
initialLoading,
loadingMore,
hasMoreToLoad,
filters,
fetchOrders,
resetAndFetch,
loadMore,
debouncedSearch,
exportToCsv,
formatCurrency,
formatDate,
getStatusProps
};
}
};
</script> </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;
}
.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 /> <PaymentDetails />
</div> </div>
<div v-else class="mt-12 flex flex-1 items-center justify-center"> <div v-else class="loading-container">
<Spinner class="h-8" /> <n-spin size="large" />
</div>
</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');
@ -38,3 +38,30 @@ 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,23 +1,25 @@
<template> <template>
<div class="p-5"> <div class="billing-payment-methods-container">
<n-card class="payment-methods-card">
<ObjectList :options="options" /> <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: [
@ -28,21 +30,21 @@ export default {
'brand', 'brand',
'stripe_mandate_id' 'stripe_mandate_id'
], ],
emptyStateMessage: this.$t('No cards added'), emptyStateMessage: $t('No cards added'),
columns: [ columns: [
{ {
label: this.$t('Card Name'), label: $t('Card Name'),
fieldname: 'name_on_card' fieldname: 'name_on_card'
}, },
{ {
label: this.$t('Card'), label: $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 this.cardBrandIcon(row.brand); return cardBrandIcon(row.brand);
}, },
suffix(row) { suffix(row) {
if (row.is_default) { if (row.is_default) {
@ -51,20 +53,20 @@ export default {
{ {
theme: 'green' theme: 'green'
}, },
() => this.$t('Default') () => $t('Default')
); );
} }
} }
}, },
{ {
label: this.$t('Expiry Date'), label: $t('Expiry Date'),
width: 0.5, width: 0.5,
format(value, row) { format(value, row) {
return `${row.expiry_month}/${row.expiry_year}`; return `${row.expiry_month}/${row.expiry_year}`;
} }
}, },
{ {
label: this.$t('Authorization'), label: $t('Authorization'),
type: 'Component', type: 'Component',
width: 1, width: 1,
align: 'center', align: 'center',
@ -86,7 +88,7 @@ export default {
return h( return h(
Tooltip, Tooltip,
{ {
text: this.$t('This card failed to pay last time. Please use another card.') text: $t('This card failed to pay last time. Please use another card.')
}, },
() => () =>
h(FeatherIcon, { h(FeatherIcon, {
@ -107,7 +109,7 @@ export default {
rowActions: ({ listResource, row }) => { rowActions: ({ listResource, row }) => {
return [ return [
{ {
label: this.$t('Set as Default'), label: $t('Set as Default'),
onClick: () => { onClick: () => {
toast.promise( toast.promise(
listResource.runDocMethod.submit({ listResource.runDocMethod.submit({
@ -115,24 +117,24 @@ export default {
name: row.name name: row.name
}), }),
{ {
loading: this.$t('Setting as default...'), loading: $t('Setting as default...'),
success: this.$t('Default card set'), success: $t('Default card set'),
error: this.$t('Failed to set default card') error: $t('Failed to set default card')
} }
); );
}, },
condition: () => !row.is_default condition: () => !row.is_default
}, },
{ {
label: this.$t('Remove'), label: $t('Remove'),
onClick: () => { onClick: () => {
if (row.is_default && this.$team.pg.payment_mode === 'Card') { if (row.is_default && $team.pg.payment_mode === 'Card') {
toast.error(this.$t('Cannot remove default card')); toast.error($t('Cannot remove default card'));
return; return;
} }
confirmDialog({ confirmDialog({
title: this.$t('Remove Card'), title: $t('Remove Card'),
message: this.$t('Are you sure you want to remove this card?'), message: $t('Are you sure you want to remove this card?'),
onSuccess: ({ hide }) => { onSuccess: ({ hide }) => {
toast.promise( toast.promise(
listResource.delete.submit(row.name, { listResource.delete.submit(row.name, {
@ -141,12 +143,12 @@ export default {
} }
}), }),
{ {
loading: this.$t('Removing card...'), loading: $t('Removing card...'),
success: this.$t('Card removed'), success: $t('Card removed'),
error: error => error: error =>
error.messages?.length error.messages?.length
? error.messages.join('\n') ? error.messages.join('\n')
: error.message || this.$t('Failed to remove card') : error.message || $t('Failed to remove card')
} }
); );
} }
@ -158,7 +160,7 @@ export default {
orderBy: 'creation desc', orderBy: 'creation desc',
primaryAction() { primaryAction() {
return { return {
label: this.$t('Add Card'), label: $t('Add Card'),
slots: { slots: {
prefix: icon('plus') prefix: icon('plus')
}, },
@ -171,16 +173,9 @@ export default {
}; };
} }
}; };
} });
},
methods: { function cardBrandIcon(brand) {
formatCurrency(value) {
if (value === 0) {
return '';
}
return this.$format.userCurrency(value);
},
cardBrandIcon(brand) {
let component = { let component = {
'master-card': defineAsyncComponent(() => 'master-card': defineAsyncComponent(() =>
import('@/components/icons/cards/MasterCard.vue') import('@/components/icons/cards/MasterCard.vue')
@ -204,6 +199,23 @@ formatCurrency(value) {
return h(component, { class: 'h-4 w-6' }); return h(component, { class: 'h-4 w-6' });
} }
}
};
</script> </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;
}
}
</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合作伙伴