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"
:title="$t('Account Recharge')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="add-prepaid-credits-modal"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Account Recharge') }}</span>
</template>
<n-space vertical :size="20">
<n-alert
v-if="showMessage" v-if="showMessage"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700" type="info"
:title="$t('Information')"
closable
> >
<FeatherIcon class="h-4" name="info" /> {{ $t('Please recharge your account balance before changing payment method.') }}
<span> </n-alert>
{{ $t('Please recharge your account balance before changing payment method.') }}
</span>
</div>
<PrepaidCreditsForm <PrepaidCreditsForm
@success=" @success="
() => { () => {
@ -18,12 +28,13 @@
} }
" "
/> />
</template> </n-space>
</Dialog> </n-modal>
</template> </template>
<script setup> <script setup>
import { NModal, NSpace, NAlert } from 'naive-ui';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import PrepaidCreditsForm from '../BuyPrepaidCreditsForm.vue'; import PrepaidCreditsForm from '../BuyPrepaidCreditsForm.vue';
import { Dialog, FeatherIcon } from 'jingrow-ui';
const props = defineProps({ const props = defineProps({
showMessage: { showMessage: {
@ -34,4 +45,53 @@ const props = defineProps({
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const show = defineModel(); const show = defineModel();
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"
:title="$t('Billing Details')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="billing-details-modal"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Billing Details') }}</span>
</template>
<n-space vertical :size="20">
<n-alert
v-if="showMessage" v-if="showMessage"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700" type="info"
:title="$t('Information')"
closable
> >
<FeatherIcon class="h-4" name="info" /> {{ $t('Please add billing details to your account before continuing.') }}
<span> {{ $t('Please add billing details to your account before continuing.') }}</span> </n-alert>
</div>
<BillingDetails <BillingDetails
ref="billingRef" ref="billingRef"
@success=" @success="
@ -17,13 +29,13 @@
} }
" "
/> />
</template> </n-space>
</Dialog> </n-modal>
</template> </template>
<script setup> <script setup>
import { NModal, NSpace, NAlert } from 'naive-ui';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import BillingDetails from './BillingDetails.vue'; import BillingDetails from './BillingDetails.vue';
import { FeatherIcon, Dialog } from 'jingrow-ui';
import { ref } from 'vue';
const props = defineProps({ const props = defineProps({
showMessage: { showMessage: {
@ -31,7 +43,57 @@ const props = defineProps({
default: false default: false
} }
}); });
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const show = defineModel(); const show = defineModel();
const billingRef = ref(null); const billingRef = ref(null);
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> <n-button
<div class="shrink-0"> type="primary"
<Button
:label="$t('Recharge')"
@click=" @click="
() => { () => {
showMessage = false; showMessage = false;
showAddPrepaidCreditsDialog = true; showAddPrepaidCreditsDialog = true;
} }
" "
:size="buttonSize"
:block="isMobile"
> >
<template #prefix> <template #icon>
<FeatherIcon class="h-4" name="plus" /> <i-lucide-plus class="h-4 w-4" />
</template> </template>
</Button> {{ $t('Recharge') }}
</n-button>
</div> </div>
</div> </n-space>
<div class="my-3 h-px bg-gray-100" /> </n-card>
<div class="flex items-center justify-between text-base text-gray-900">
<div class="flex flex-col gap-1.5"> <n-card class="payment-details-card" :title="$t('Billing Address')">
<div class="font-medium">{{ $t('Billing Address') }}</div> <n-space vertical :size="16">
<div v-if="billingDetailsSummary" class="leading-5 text-gray-700"> <div class="address-section">
{{ billingDetailsSummary }} <div class="address-info">
<div v-if="billingDetailsSummary" class="address-text">
{{ billingDetailsSummary }}
</div>
<div v-else class="address-empty">
{{ $t('No address') }}
</div>
</div> </div>
<div v-else class="text-gray-700">{{ $t('No address') }}</div> <n-button
</div> :type="billingDetailsSummary ? 'default' : 'primary'"
<div class="shrink-0">
<Button
:label="billingAddressButtonLabel"
@click=" @click="
() => { () => {
showMessage = false; showMessage = false;
showBillingDetailsDialog = true; showBillingDetailsDialog = true;
} }
" "
:size="buttonSize"
:block="isMobile"
> >
<template v-if="!billingDetailsSummary" #prefix> <template v-if="!billingDetailsSummary" #icon>
<FeatherIcon class="h-4" name="plus" /> <i-lucide-plus class="h-4 w-4" />
</template> </template>
</Button> {{ billingAddressButtonLabel }}
</n-button>
</div> </div>
</div> </n-space>
</div> </n-card>
</div> </n-space>
<BillingDetailsDialog <BillingDetailsDialog
v-if="showBillingDetailsDialog" v-if="showBillingDetailsDialog"
v-model="showBillingDetailsDialog" v-model="showBillingDetailsDialog"
@ -65,16 +74,11 @@
/> />
</template> </template>
<script setup> <script setup>
import DropdownItem from './DropdownItem.vue'; import { NCard, NSpace, NButton } from 'naive-ui';
import BillingDetailsDialog from './BillingDetailsDialog.vue'; import BillingDetailsDialog from './BillingDetailsDialog.vue';
import AddPrepaidCreditsDialog from './AddPrepaidCreditsDialog.vue'; import AddPrepaidCreditsDialog from './AddPrepaidCreditsDialog.vue';
import { Dropdown, Button, FeatherIcon, createResource } from 'jingrow-ui'; import { createResource } from 'jingrow-ui';
import { import { computed, ref, inject, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
confirmDialog,
renderDialog,
} from '../../utils/components';
import { computed, ref, inject, h, defineAsyncComponent, onMounted, nextTick, getCurrentInstance } from 'vue';
import router from '../../router';
// //
const countryToZh = { const countryToZh = {
@ -96,6 +100,10 @@ const {
const showBillingDetailsDialog = ref(false); const showBillingDetailsDialog = ref(false);
const showAddPrepaidCreditsDialog = ref(false); const showAddPrepaidCreditsDialog = ref(false);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const currency = computed(() => (team.pg.currency == 'CNY' ? '¥' : '$')); const currency = computed(() => (team.pg.currency == 'CNY' ? '¥' : '$'));
@ -193,8 +201,14 @@ const billingAddressButtonLabel = computed(() => {
return billingDetailsSummary.value ? $t('Edit') : $t('Add Billing Address'); return billingDetailsSummary.value ? $t('Edit') : $t('Add Billing Address');
}); });
function handleResize() {
windowWidth.value = window.innerWidth;
}
// onMounted // onMounted
onMounted(() => { onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
// //
nextTick(() => { nextTick(() => {
if (!team.pg.payment_mode) { if (!team.pg.payment_mode) {
@ -203,6 +217,10 @@ onMounted(() => {
}); });
}); });
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
function payUnpaidInvoices() { function payUnpaidInvoices() {
let _unpaidInvoices = unpaidInvoices.data; let _unpaidInvoices = unpaidInvoices.data;
if (_unpaidInvoices.length > 1) { if (_unpaidInvoices.length > 1) {
@ -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" /> >
<ErrorMessage :message="$t('You do not have permission to view the billing page')" /> <n-tab-pane
v-for="tab in filteredTabs"
:key="tab.route.name"
:name="tab.route.name"
:tab="tab.label"
>
<router-view :tab="tab" />
</n-tab-pane>
</n-tabs>
</div>
<div v-else class="no-permission-container">
<n-result
status="403"
:title="$t('Access Denied')"
:description="$t('You do not have permission to view the billing page')"
>
<template #icon>
<i-lucide-alert-triangle class="h-12 w-12 text-red-600" />
</template>
</n-result>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
import { Tabs, Breadcrumbs } from 'jingrow-ui'; import { computed, ref, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NTabs, NTabPane, NBreadcrumb, NBreadcrumbItem, NResult } from 'naive-ui';
import Header from '../components/Header.vue'; import Header from '../components/Header.vue';
import TabsWithRouter from '../components/TabsWithRouter.vue';
export default { const instance = getCurrentInstance();
name: 'Billing', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
components: { const $team = instance?.appContext.config.globalProperties.$team;
Header, const $session = instance?.appContext.config.globalProperties.$session;
FBreadcrumbs: Breadcrumbs,
FTabs: Tabs, const route = useRoute();
TabsWithRouter, const router = useRouter();
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const tabs = [
{ label: $t('Overview'), route: { name: 'BillingOverview' } },
{ label: $t('Order Records'), route: { name: 'BillingOrders' } },
{ label: $t('Balance Details'), route: { name: 'BillingBalances' } },
{
label: $t('Developer Earnings'),
route: { name: 'BillingMarketplacePayouts' },
requireDeveloper: true,
requirePro: true
}, },
data() { ];
return {
currentTab: 0,
tabs: [
{ label: this.$t('Overview'), route: { name: 'BillingOverview' } },
{ label: this.$t('Order Records'), route: { name: 'BillingOrders' } },
{ label: this.$t('Balance Details'), route: { name: 'BillingBalances' } },
{
label: this.$t('Developer Earnings'),
route: { name: 'BillingMarketplacePayouts' },
requireDeveloper: true,
requirePro: true
},
],
};
},
computed: {
filteredTabs() {
return this.tabs.filter(tab => {
//
if (tab.requireDeveloper && !this.$team.pg.is_developer) {
return false;
}
// const filteredTabs = computed(() => {
if (tab.requirePro && !this.$team.pg.is_pro) { return tabs.filter(tab => {
return false; //
} if (tab.requireDeveloper && !$team.pg.is_developer) {
return false;
return true;
});
} }
//
if (tab.requirePro && !$team.pg.is_pro) {
return false;
}
return true;
});
});
const activeTab = computed({
get() {
return route.name || 'BillingOverview';
}, },
}; set(value) {
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,205 +1,233 @@
<template> <template>
<div class="p-5"> <div class="billing-balances-container">
<ObjectList :options="options"> <n-card class="balances-card">
<template #header-right> <ObjectList :options="options">
<Button <template #header-right>
icon="download" <n-button
appearance="primary" type="primary"
@click="exportToCsv" :size="buttonSize"
:loading="exporting" :loading="exporting"
> :block="isMobile"
{{ $t('Export') }} @click="exportToCsv"
</Button> >
</template> <template #icon>
</ObjectList> <i-lucide-download class="h-4 w-4" />
</template>
{{ $t('Export') }}
</n-button>
</template>
</ObjectList>
</n-card>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { NCard, NButton } from 'naive-ui';
import ObjectList from '../components/ObjectList.vue'; import ObjectList from '../components/ObjectList.vue';
import { Button, createResource } from 'jingrow-ui'; import { createResource } from 'jingrow-ui';
import { unparse } from 'papaparse'; import { unparse } from 'papaparse';
export default { const instance = getCurrentInstance();
name: 'BillingBalances', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
props: ['tab'], const $team = instance?.appContext.config.globalProperties.$team;
components: {
ObjectList,
Button
},
data() {
return {
exporting: false
};
},
computed: {
options() {
return {
pagetype: 'Balance Transaction',
fields: ['type', 'source', 'invoice', 'description'],
columns: [
{
label: this.$t('Time'),
fieldname: 'creation',
format(value) {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
}
},
{
label: this.$t('Description'),
fieldname: 'description',
format(value, row) {
if (value !== null && value !== undefined) {
return value;
}
if (row.type === 'Applied To Invoice' && row.invoice) { const exporting = ref(false);
return this.$t('Applied to Invoice {invoice}', { invoice: row.invoice }); const windowWidth = ref(window.innerWidth);
}
if (row.source === 'Prepaid Credits') { const isMobile = computed(() => windowWidth.value <= 768);
return this.$t('Balance Recharge'); const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
}
if (row.source === 'Free Credits') { const props = defineProps(['tab']);
return this.$t('Free Credits'); const options = computed(() => {
} return {
pagetype: 'Balance Transaction',
return row.amount < 0 ? row.type : row.source; fields: ['type', 'source', 'invoice', 'description'],
} columns: [
}, {
{ label: $t('Time'),
label: this.$t('Amount'), fieldname: 'creation',
fieldname: 'amount', format(value) {
align: 'right', return new Date(value).toLocaleString('zh-CN', {
format: this.formatCurrency year: 'numeric',
}, month: '2-digit',
{ day: '2-digit',
label: this.$t('Balance'), hour: '2-digit',
fieldname: 'ending_balance', minute: '2-digit',
align: 'right', second: '2-digit'
format: this.formatCurrency }).replace(/\//g, '-');
}
],
filters: {
pagestatus: 1,
team: this.$team.name
},
orderBy: 'creation desc'
};
}
},
created() {
//
this.exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: this.handleExportSuccess,
onError: this.handleExportError
});
},
methods: {
formatCurrency(value) {
if (value === 0) {
return '';
}
return this.$format.userCurrency(value);
},
formatDate(dateString) {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
},
exportToCsv() {
this.exporting = true;
this.exportResource.submit({
page: 1,
page_size: 1000
});
},
handleExportSuccess(response) {
const transactions = response.transactions || [];
// CSV
const fields = [
this.$t('Time'),
this.$t('Description'),
this.$t('Amount'),
this.$t('Balance')
];
//
const csvData = transactions.map(row => {
// 使
let description = row.description;
// 使
if (!description) {
if (row.type === 'Applied To Invoice' && row.invoice) {
description = this.$t('Applied to Invoice {invoice}', { invoice: row.invoice });
} else if (row.source === 'Prepaid Credits') {
description = this.$t('Balance Recharge');
} else if (row.source === 'Free Credits') {
description = this.$t('Free Credits');
} else {
description = row.amount < 0 ? row.type : row.source;
}
} }
},
{
label: $t('Description'),
fieldname: 'description',
format(value, row) {
if (value !== null && value !== undefined) {
return value;
}
return [ if (row.type === 'Applied To Invoice' && row.invoice) {
this.formatDate(row.creation), return $t('Applied to Invoice {invoice}', { invoice: row.invoice });
description, }
row.amount,
row.ending_balance
];
});
// if (row.source === 'Prepaid Credits') {
csvData.unshift(fields); return $t('Balance Recharge');
}
// CSV if (row.source === 'Free Credits') {
let csv = unparse(csvData); return $t('Free Credits');
}
// BOM return row.amount < 0 ? row.type : row.source;
csv = '\uFEFF' + csv; }
},
// {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); label: $t('Amount'),
const today = new Date().toISOString().split('T')[0]; fieldname: 'amount',
const filename = `${this.$t('Balance Records')}-${today}.csv`; align: 'right',
const link = document.createElement('a'); format: formatCurrency
link.href = URL.createObjectURL(blob); },
link.download = filename; {
link.click(); label: $t('Balance'),
URL.revokeObjectURL(link.href); fieldname: 'ending_balance',
align: 'right',
this.exporting = false; format: formatCurrency
}
],
filters: {
pagestatus: 1,
team: $team.name
}, },
handleExportError(error) { orderBy: 'creation desc'
console.error(this.$t('Failed to export data'), error); };
this.exporting = false; });
}
//
const exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: handleExportSuccess,
onError: handleExportError
});
function formatCurrency(value) {
if (value === 0) {
return '';
} }
}; const $format = instance?.appContext.config.globalProperties.$format;
</script> return $format?.userCurrency(value) || value;
<style scoped>
/* 添加悬浮效果 */
.flex-col:hover {
background-color: #f0f0f0; /* 可按需调整颜色 */
} }
/* 允许内容复制 */ function formatDate(dateString) {
.flex-col * { return new Date(dateString).toLocaleString('zh-CN', {
user-select: text; year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
}
function exportToCsv() {
exporting.value = true;
exportResource.submit({
page: 1,
page_size: 1000
});
}
function handleExportSuccess(response) {
const transactions = response.transactions || [];
// CSV
const fields = [
$t('Time'),
$t('Description'),
$t('Amount'),
$t('Balance')
];
//
const csvData = transactions.map(row => {
// 使
let description = row.description;
// 使
if (!description) {
if (row.type === 'Applied To Invoice' && row.invoice) {
description = $t('Applied to Invoice {invoice}', { invoice: row.invoice });
} else if (row.source === 'Prepaid Credits') {
description = $t('Balance Recharge');
} else if (row.source === 'Free Credits') {
description = $t('Free Credits');
} else {
description = row.amount < 0 ? row.type : row.source;
}
}
return [
formatDate(row.creation),
description,
row.amount,
row.ending_balance
];
});
//
csvData.unshift(fields);
// CSV
let csv = unparse(csvData);
// BOM
csv = '\uFEFF' + csv;
//
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const today = new Date().toISOString().split('T')[0];
const filename = `${$t('Balance Records')}-${today}.csv`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
exporting.value = false;
}
function handleExportError(error) {
console.error($t('Failed to export data'), error);
exporting.value = false;
}
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.billing-balances-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.balances-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
@media (max-width: 768px) {
.billing-balances-container {
padding: 16px;
}
} }
</style> </style>

View File

@ -1,105 +1,171 @@
<template> <template>
<div class="p-5"> <div class="billing-marketplace-payouts-container">
<ObjectList :options="options" /> <n-card class="payouts-card">
<Dialog <ObjectList :options="options" />
v-model="payoutDialog" </n-card>
:options="{ size: '6xl', title: showPayout?.name }" <n-modal
v-model:show="payoutDialog"
preset="card"
:title="showPayout?.name"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="payout-dialog"
> >
<template #body-content> <template #header>
<template v-if="showPayout"> <span class="text-lg font-semibold">{{ showPayout?.name }}</span>
<div
v-if="showPayout.status === 'Empty'"
class="text-base text-gray-600"
>
{{ $t('No content to display') }}
</div>
<PayoutTable v-else :payoutId="showPayout.name" />
</template>
</template> </template>
</Dialog> <template v-if="showPayout">
<n-empty
v-if="showPayout.status === 'Empty'"
:description="$t('No content to display')"
/>
<PayoutTable v-else :payoutId="showPayout.name" />
</template>
</n-modal>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { NCard, NModal, NEmpty } from 'naive-ui';
import ObjectList from '../components/ObjectList.vue'; import ObjectList from '../components/ObjectList.vue';
import PayoutTable from '../components/PayoutTable.vue'; import PayoutTable from '../components/PayoutTable.vue';
export default { const instance = getCurrentInstance();
name: 'BillingMarketplacePayouts', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
props: ['tab'], const $team = instance?.appContext.config.globalProperties.$team;
data() {
return {
payoutDialog: false,
showPayout: null
};
},
components: {
ObjectList,
PayoutTable
},
computed: {
options() {
return {
pagetype: 'Payout Order',
fields: [
'period_end',
'mode_of_payment',
'status',
'net_total_cny',
'net_total_usd'
],
filterControls: () => {
return [
{
type: 'select',
label: this.$t('Status'),
class: !this.$isMobile ? 'w-36' : '',
fieldname: 'status',
options: [
{ label: '', value: '' },
{ label: this.$t('Pending Settlement'), value: 'Draft' },
{ label: this.$t('Paid'), value: 'Paid' },
{ label: this.$t('Settled'), value: 'Commissioned' }
]
}
];
},
orderBy: 'creation desc',
columns: [
{
label: this.$t('Date'),
fieldname: 'period_end',
format(value) {
return Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(value));
}
},
{ label: this.$t('Payment Method'), fieldname: 'mode_of_payment' },
{ label: this.$t('Status'), fieldname: 'status', type: 'Badge' },
{
label: this.$t('Total'),
fieldname: 'net_total_cny',
align: 'right',
format: (_, row) => {
let total = 0;
if (this.$team.pg.currency === 'CNY') {
total = row.net_total_cny + row.net_total_usd * 82;
} else {
total = row.net_total_cny / 82 + row.net_total_usd;
}
return this.$format.userCurrency(total); const props = defineProps(['tab']);
}
} const payoutDialog = ref(false);
], const showPayout = ref(null);
onRowClick: row => { const windowWidth = ref(window.innerWidth);
this.showPayout = row;
this.payoutDialog = true; const isMobile = computed(() => windowWidth.value <= 768);
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '1200px',
maxWidth: isMobile.value ? '95vw' : '90vw',
}));
const options = computed(() => {
return {
pagetype: 'Payout Order',
fields: [
'period_end',
'mode_of_payment',
'status',
'net_total_cny',
'net_total_usd'
],
filterControls: () => {
return [
{
type: 'select',
label: $t('Status'),
class: !isMobile.value ? 'w-36' : '',
fieldname: 'status',
options: [
{ label: '', value: '' },
{ label: $t('Pending Settlement'), value: 'Draft' },
{ label: $t('Paid'), value: 'Paid' },
{ label: $t('Settled'), value: 'Commissioned' }
]
} }
}; ];
},
orderBy: 'creation desc',
columns: [
{
label: $t('Date'),
fieldname: 'period_end',
format(value) {
return Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(value));
}
},
{ label: $t('Payment Method'), fieldname: 'mode_of_payment' },
{ label: $t('Status'), fieldname: 'status', type: 'Badge' },
{
label: $t('Total'),
fieldname: 'net_total_cny',
align: 'right',
format: (_, row) => {
let total = 0;
const $format = instance?.appContext.config.globalProperties.$format;
if ($team.pg.currency === 'CNY') {
total = row.net_total_cny + row.net_total_usd * 82;
} else {
total = row.net_total_cny / 82 + row.net_total_usd;
}
return $format?.userCurrency(total) || total;
}
}
],
onRowClick: row => {
showPayout.value = row;
payoutDialog.value = true;
} }
} };
}; });
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script> </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,147 +1,224 @@
<template> <template>
<div class="p-4"> <div class="billing-orders-container">
<div class="mb-4 flex justify-between"> <n-card class="orders-card">
<div> <template #header>
<FInput <div class="orders-header">
v-model="filters.search" <n-input
:placeholder="$t('Search orders...')" v-model:value="filters.search"
type="text" :placeholder="$t('Search orders...')"
class="w-60" clearable
@input="debouncedSearch" :size="inputSize"
> class="search-input"
<template #prefix> @input="debouncedSearch"
<i-lucide-search class="h-4 w-4 text-gray-500" />
</template>
</FInput>
</div>
<div class="flex items-center space-x-2">
<Button
icon="download"
appearance="primary"
@click="exportToCsv"
>
{{ $t('Export') }}
</Button>
<Button
icon="refresh-cw"
appearance="minimal"
@click="resetAndFetch"
/>
</div>
</div>
<Card class="overflow-hidden !rounded-none !border-0">
<template v-if="orders.length === 0">
<div class="flex h-60 flex-col items-center justify-center space-y-2 p-4 text-center">
<i-lucide-file-text class="h-8 w-8 text-gray-400" />
<p class="text-base font-medium">{{ $t('No Order Records') }}</p>
<p class="text-sm text-gray-600">{{ $t('No order records to display') }}</p>
</div>
</template>
<template v-else>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b text-left text-sm font-medium text-gray-600">
<th
v-for="column in columns"
:key="column.key"
class="whitespace-nowrap px-4 py-3"
:class="column.class"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="order in orders"
:key="order.name || order.order_id"
class="border-b text-sm hover:bg-gray-50"
>
<td class="px-4 py-3">{{ formatDate(order.creation) }}</td>
<td class="px-4 py-3">{{ order.title || '-' }}</td>
<td class="px-4 py-3">{{ order.order_id || '-' }}</td>
<td class="px-4 py-3">{{ order.trade_no || '-' }}</td>
<td class="px-4 py-3">{{ order.order_type || '-' }}</td>
<td class="px-4 py-3">{{ order.payment_method || '-' }}</td>
<td class="px-4 py-3">{{ order.description || '-' }}</td>
<td class="px-4 py-3 text-right">{{ formatCurrency(order.total) }}</td>
<td class="px-4 py-3">
<StatusIndicator :status="getStatusProps(order.status)" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between p-4">
<div class="text-sm text-gray-600">
{{ $t('Showing {count} orders, total {total}', { count: orders.length, total: totalCount }) }}
</div>
<Button
v-if="hasMoreToLoad"
@click="loadMore"
:loading="loadingMore"
appearance="primary"
> >
{{ $t('Load More') }} <template #prefix>
</Button> <i-lucide-search class="h-4 w-4" />
</template>
</n-input>
<n-space :size="12">
<n-button
type="primary"
:size="buttonSize"
:loading="exporting"
@click="exportToCsv"
:block="isMobile"
>
<template #icon>
<i-lucide-download class="h-4 w-4" />
</template>
{{ $t('Export') }}
</n-button>
<n-button
:size="buttonSize"
@click="resetAndFetch"
:loading="initialLoading"
:block="isMobile"
>
<template #icon>
<i-lucide-refresh-cw class="h-4 w-4" />
</template>
</n-button>
</n-space>
</div> </div>
</template> </template>
</Card>
<n-empty
v-if="!initialLoading && orders.length === 0"
:description="$t('No order records to display')"
class="empty-state"
>
<template #icon>
<i-lucide-file-text class="h-12 w-12 text-gray-400" />
</template>
</n-empty>
<n-data-table
v-else
:columns="tableColumns"
:data="orders"
:loading="initialLoading"
:pagination="false"
:scroll-x="isMobile ? 1200 : undefined"
:bordered="false"
:single-line="false"
class="orders-table"
size="medium"
/>
<div v-if="orders.length > 0" class="orders-footer">
<div class="orders-info">
{{ $t('Showing {count} orders, total {total}', { count: orders.length, total: totalCount }) }}
</div>
<n-button
v-if="hasMoreToLoad"
type="primary"
:loading="loadingMore"
:size="buttonSize"
:block="isMobile"
@click="loadMore"
>
{{ $t('Load More') }}
</n-button>
</div>
</n-card>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, reactive, computed, onMounted, getCurrentInstance } from 'vue'; import { ref, reactive, computed, onMounted, onUnmounted, h, getCurrentInstance } from 'vue';
import { Button, Card, Input as FInput, createResource } from 'jingrow-ui'; import { NCard, NInput, NButton, NSpace, NDataTable, NEmpty, NTag } from 'naive-ui';
import StatusIndicator from '../components/StatusIndicator.vue'; import { createResource } from 'jingrow-ui';
import { unparse } from 'papaparse'; import { unparse } from 'papaparse';
export default { const instance = getCurrentInstance();
name: 'BillingOrders', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
components: { const pageSize = 20;
Button, const orders = ref([]);
Card, const totalCount = ref(0);
FInput, const currentPage = ref(1);
StatusIndicator, const initialLoading = ref(true);
}, const loadingMore = ref(false);
setup() { const exporting = ref(false);
const instance = getCurrentInstance(); const windowWidth = ref(window.innerWidth);
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const pageSize = 20;
const orders = ref([]);
const totalCount = ref(0);
const currentPage = ref(1);
const initialLoading = ref(true);
const loadingMore = ref(false);
const filters = reactive({ const isMobile = computed(() => windowWidth.value <= 768);
search: '' const inputSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
}); const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
// const filters = reactive({
const columns = [ search: ''
{ 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 ordersResource = createResource({ 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({
url: 'jcloud.api.billing.get_orders', url: 'jcloud.api.billing.get_orders',
transform(response) { transform(response) {
return { return {
@ -165,178 +242,247 @@ export default {
initialLoading.value = false; initialLoading.value = false;
loadingMore.value = false; loadingMore.value = false;
}, },
onError(error) { onError(error) {
initialLoading.value = false; initialLoading.value = false;
loadingMore.value = false; loadingMore.value = false;
} }
}); });
// //
const exportResource = createResource({ const exportResource = createResource({
url: 'jcloud.api.billing.get_orders', url: 'jcloud.api.billing.get_orders',
onSuccess(response) { onSuccess(response) {
const orders = response.orders || []; const orders = response.orders || [];
// CSV // CSV
const fields = [ const fields = [
$t('Title'), $t('Title'),
$t('Order ID'), $t('Order ID'),
$t('Transaction ID'), $t('Transaction ID'),
$t('Order Type'), $t('Order Type'),
$t('Payment Method'), $t('Payment Method'),
$t('Description'), $t('Description'),
$t('Amount'), $t('Amount'),
$t('Status'), $t('Status'),
$t('Creation Time') $t('Creation Time')
]; ];
// //
const csvData = orders.map(order => [ const csvData = orders.map(order => [
order.title || '', order.title || '',
order.order_id || '', order.order_id || '',
order.trade_no || '', order.trade_no || '',
order.order_type || '', order.order_type || '',
order.payment_method || '', order.payment_method || '',
order.description || '', order.description || '',
order.total || 0, order.total || 0,
order.status || '', order.status || '',
order.creation || '' order.creation || ''
]); ]);
// //
csvData.unshift(fields); csvData.unshift(fields);
// CSV // CSV
let csv = unparse(csvData); let csv = unparse(csvData);
// BOM // BOM
csv = '\uFEFF' + csv; csv = '\uFEFF' + csv;
// //
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 = `${$t('Order Records')}-${today}.csv`; const filename = `${$t('Order 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);
}, exporting.value = false;
onError(error) { },
// onError(error) {
} exporting.value = false;
}); }
});
// //
function fetchOrders() { function fetchOrders() {
if (currentPage.value === 1) { if (currentPage.value === 1) {
initialLoading.value = true; initialLoading.value = true;
} else { } else {
loadingMore.value = true; loadingMore.value = true;
} }
ordersResource.submit({ ordersResource.submit({
page: currentPage.value, page: currentPage.value,
page_size: pageSize, page_size: pageSize,
search: filters.search, search: filters.search,
}); });
} }
// //
function resetAndFetch() { function resetAndFetch() {
currentPage.value = 1; currentPage.value = 1;
fetchOrders(); fetchOrders();
} }
// //
function loadMore() { function loadMore() {
if (loadingMore.value) return; if (loadingMore.value) return;
currentPage.value += 1; currentPage.value += 1;
loadingMore.value = true; loadingMore.value = true;
fetchOrders(); fetchOrders();
} }
// //
let searchTimeout; let searchTimeout;
function debouncedSearch() { function debouncedSearch() {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
resetAndFetch(); resetAndFetch();
}, 300); }, 300);
} }
// CSV // CSV
function exportToCsv() { function exportToCsv() {
exportResource.submit({ exporting.value = true;
page: 1, exportResource.submit({
page_size: 1000, // page: 1,
search: filters.search, page_size: 1000, //
}); search: filters.search,
} });
}
// //
function formatCurrency(amount) { function formatCurrency(amount) {
if (amount === undefined || amount === null) return '-'; if (amount === undefined || amount === null) return '-';
return new Intl.NumberFormat('zh-CN', { return new Intl.NumberFormat('zh-CN', {
style: 'currency', style: 'currency',
currency: 'CNY', currency: 'CNY',
}).format(amount); }).format(amount);
} }
// //
function formatDate(dateString) { function formatDate(dateString) {
if (!dateString) return '-'; if (!dateString) return '-';
const date = new Date(dateString); const date = new Date(dateString);
if (isNaN(date.getTime())) return '-'; if (isNaN(date.getTime())) return '-';
// YYYY-MM-DD HH:MM // YYYY-MM-DD HH:MM
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`; return `${year}-${month}-${day} ${hours}:${minutes}`;
} }
// //
function getStatusProps(status) { function getStatusProps(status) {
const statusMap = { const statusMap = {
'待支付': { label: $t('Pending Payment'), color: 'orange' }, '待支付': { label: $t('Pending Payment'), color: 'orange' },
'已支付': { label: $t('Paid'), color: 'green' }, '已支付': { label: $t('Paid'), color: 'green' },
'交易成功': { label: $t('Transaction Successful'), color: 'green' }, '交易成功': { label: $t('Transaction Successful'), color: 'green' },
'已取消': { label: $t('Cancelled'), color: 'red' }, '已取消': { label: $t('Cancelled'), color: 'red' },
'已退款': { label: $t('Refunded'), color: 'red' }, '已退款': { label: $t('Refunded'), color: 'red' },
}; };
return statusMap[status] || { label: status || '', color: 'blue' }; return statusMap[status] || { label: status || '', color: 'blue' };
} }
// function handleResize() {
onMounted(() => { windowWidth.value = window.innerWidth;
fetchOrders(); }
});
return { //
columns, onMounted(() => {
orders, handleResize();
totalCount, window.addEventListener('resize', handleResize);
currentPage, fetchOrders();
initialLoading, });
loadingMore,
hasMoreToLoad, onUnmounted(() => {
filters, window.removeEventListener('resize', handleResize);
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 />
> </div>
<PaymentDetails /> <div v-else class="loading-container">
</div> <n-spin size="large" />
<div v-else class="mt-12 flex flex-1 items-center justify-center"> </div>
<Spinner class="h-8" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, provide, inject, onMounted, onUnmounted, ref } from 'vue';
import { NSpin } from 'naive-ui';
import PaymentDetails from '../components/billing/PaymentDetails.vue'; import PaymentDetails from '../components/billing/PaymentDetails.vue';
import { Spinner, createResource } from 'jingrow-ui'; import { createResource } from 'jingrow-ui';
import { computed, provide, inject } from 'vue';
const team = inject('team'); const team = inject('team');
@ -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,209 +1,221 @@
<template> <template>
<div class="p-5"> <div class="billing-payment-methods-container">
<ObjectList :options="options" /> <n-card class="payment-methods-card">
<ObjectList :options="options" />
</n-card>
</div> </div>
</template> </template>
<script> <script setup>
import { defineAsyncComponent, h } from 'vue'; import { defineAsyncComponent, h, computed, getCurrentInstance } from 'vue';
import { NCard } from 'naive-ui';
import ObjectList from '../components/ObjectList.vue'; import ObjectList from '../components/ObjectList.vue';
import { Badge, FeatherIcon, Tooltip } from 'jingrow-ui'; import { Badge, FeatherIcon, Tooltip } from 'jingrow-ui';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { confirmDialog, renderDialog, icon } from '../utils/components'; import { confirmDialog, renderDialog, icon } from '../utils/components';
export default { const instance = getCurrentInstance();
name: 'BillingPaymentMethods', const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
props: ['tab'], const $team = instance?.appContext.config.globalProperties.$team;
components: {
ObjectList const props = defineProps(['tab']);
},
computed: { const options = computed(() => {
options() { return {
return { pagetype: 'Stripe Payment Method',
pagetype: 'Stripe Payment Method', fields: [
fields: [ 'name',
'name', 'is_default',
'is_default', 'expiry_month',
'expiry_month', 'expiry_year',
'expiry_year', 'brand',
'brand', 'stripe_mandate_id'
'stripe_mandate_id' ],
], emptyStateMessage: $t('No cards added'),
emptyStateMessage: this.$t('No cards added'), columns: [
columns: [ {
{ label: $t('Card Name'),
label: this.$t('Card Name'), fieldname: 'name_on_card'
fieldname: 'name_on_card' },
}, {
{ label: $t('Card'),
label: this.$t('Card'), fieldname: 'last_4',
fieldname: 'last_4', width: 1.5,
width: 1.5, format(value) {
format(value) { return `•••• ${value}`;
return `•••• ${value}`; },
}, prefix: row => {
prefix: row => { return cardBrandIcon(row.brand);
return this.cardBrandIcon(row.brand); },
}, suffix(row) {
suffix(row) { if (row.is_default) {
if (row.is_default) { return h(
return h( Badge,
Badge, {
{ theme: 'green'
theme: 'green' },
}, () => $t('Default')
() => this.$t('Default') );
);
}
}
},
{
label: this.$t('Expiry Date'),
width: 0.5,
format(value, row) {
return `${row.expiry_month}/${row.expiry_year}`;
}
},
{
label: this.$t('Authorization'),
type: 'Component',
width: 1,
align: 'center',
component({ row }) {
if (row.stripe_mandate_id) {
return h(FeatherIcon, {
name: 'check-circle',
class: 'h-4 w-4 text-green-600'
});
}
}
},
{
label: '',
type: 'Component',
align: 'right',
component({ row }) {
if (row.is_default && row.stripe_payment_method) {
return h(
Tooltip,
{
text: this.$t('This card failed to pay last time. Please use another card.')
},
() =>
h(FeatherIcon, {
name: 'alert-circle',
class: 'h-4 w-4 text-red-600'
})
);
}
}
},
{
label: '',
fieldname: 'creation',
type: 'Timestamp',
align: 'right'
} }
], }
rowActions: ({ listResource, row }) => { },
return [ {
{ label: $t('Expiry Date'),
label: this.$t('Set as Default'), width: 0.5,
onClick: () => { format(value, row) {
return `${row.expiry_month}/${row.expiry_year}`;
}
},
{
label: $t('Authorization'),
type: 'Component',
width: 1,
align: 'center',
component({ row }) {
if (row.stripe_mandate_id) {
return h(FeatherIcon, {
name: 'check-circle',
class: 'h-4 w-4 text-green-600'
});
}
}
},
{
label: '',
type: 'Component',
align: 'right',
component({ row }) {
if (row.is_default && row.stripe_payment_method) {
return h(
Tooltip,
{
text: $t('This card failed to pay last time. Please use another card.')
},
() =>
h(FeatherIcon, {
name: 'alert-circle',
class: 'h-4 w-4 text-red-600'
})
);
}
}
},
{
label: '',
fieldname: 'creation',
type: 'Timestamp',
align: 'right'
}
],
rowActions: ({ listResource, row }) => {
return [
{
label: $t('Set as Default'),
onClick: () => {
toast.promise(
listResource.runDocMethod.submit({
method: 'set_default',
name: row.name
}),
{
loading: $t('Setting as default...'),
success: $t('Default card set'),
error: $t('Failed to set default card')
}
);
},
condition: () => !row.is_default
},
{
label: $t('Remove'),
onClick: () => {
if (row.is_default && $team.pg.payment_mode === 'Card') {
toast.error($t('Cannot remove default card'));
return;
}
confirmDialog({
title: $t('Remove Card'),
message: $t('Are you sure you want to remove this card?'),
onSuccess: ({ hide }) => {
toast.promise( toast.promise(
listResource.runDocMethod.submit({ listResource.delete.submit(row.name, {
method: 'set_default', onSuccess() {
name: row.name hide();
}
}), }),
{ {
loading: this.$t('Setting as default...'), loading: $t('Removing card...'),
success: this.$t('Default card set'), success: $t('Card removed'),
error: this.$t('Failed to set default card') error: error =>
error.messages?.length
? error.messages.join('\n')
: error.message || $t('Failed to remove card')
} }
); );
},
condition: () => !row.is_default
},
{
label: this.$t('Remove'),
onClick: () => {
if (row.is_default && this.$team.pg.payment_mode === 'Card') {
toast.error(this.$t('Cannot remove default card'));
return;
}
confirmDialog({
title: this.$t('Remove Card'),
message: this.$t('Are you sure you want to remove this card?'),
onSuccess: ({ hide }) => {
toast.promise(
listResource.delete.submit(row.name, {
onSuccess() {
hide();
}
}),
{
loading: this.$t('Removing card...'),
success: this.$t('Card removed'),
error: error =>
error.messages?.length
? error.messages.join('\n')
: error.message || this.$t('Failed to remove card')
}
);
}
});
} }
} });
]; }
}
];
},
orderBy: 'creation desc',
primaryAction() {
return {
label: $t('Add Card'),
slots: {
prefix: icon('plus')
}, },
orderBy: 'creation desc', onClick: () => {
primaryAction() { let StripeCardDialog = defineAsyncComponent(() =>
return { import('../components/StripeCardDialog.vue')
label: this.$t('Add Card'), );
slots: { renderDialog(StripeCardDialog);
prefix: icon('plus')
},
onClick: () => {
let StripeCardDialog = defineAsyncComponent(() =>
import('../components/StripeCardDialog.vue')
);
renderDialog(StripeCardDialog);
}
};
} }
}; };
} }
}, };
methods: { });
formatCurrency(value) {
if (value === 0) {
return '';
}
return this.$format.userCurrency(value);
},
cardBrandIcon(brand) {
let component = {
'master-card': defineAsyncComponent(() =>
import('@/components/icons/cards/MasterCard.vue')
),
visa: defineAsyncComponent(() =>
import('@/components/icons/cards/Visa.vue')
),
amex: defineAsyncComponent(() =>
import('@/components/icons/cards/Amex.vue')
),
jcb: defineAsyncComponent(() =>
import('@/components/icons/cards/JCB.vue')
),
generic: defineAsyncComponent(() =>
import('@/components/icons/cards/Generic.vue')
),
'union-pay': defineAsyncComponent(() =>
import('@/components/icons/cards/UnionPay.vue')
)
}[brand || 'generic'];
return h(component, { class: 'h-4 w-6' }); function cardBrandIcon(brand) {
} let component = {
} 'master-card': defineAsyncComponent(() =>
}; import('@/components/icons/cards/MasterCard.vue')
),
visa: defineAsyncComponent(() =>
import('@/components/icons/cards/Visa.vue')
),
amex: defineAsyncComponent(() =>
import('@/components/icons/cards/Amex.vue')
),
jcb: defineAsyncComponent(() =>
import('@/components/icons/cards/JCB.vue')
),
generic: defineAsyncComponent(() =>
import('@/components/icons/cards/Generic.vue')
),
'union-pay': defineAsyncComponent(() =>
import('@/components/icons/cards/UnionPay.vue')
)
}[brand || 'generic'];
return h(component, { class: 'h-4 w-6' });
}
</script> </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合作伙伴