dev #3

Merged
jingrow merged 96 commits from dev into main 2026-01-13 22:47:33 +08:00
10 changed files with 1492 additions and 903 deletions
Showing only changes of commit 31b392b359 - Show all commits

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 Field Download 字段 下载
107 Fieldname Draft 字段名 草案
108 Fieldtype Due Date 字段类型 到期日
109 Export 导出
110 File Name Export as CSV 文件名 导出为CSV
111 File Size Duration 文件大小 持续时间
112 File Type JERP Partner 文件类型 JERP合作伙伴