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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1 API Key API密钥
106 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合作伙伴