账单页面及弹窗基于naive ui重构
This commit is contained in:
parent
bbacb2deb6
commit
31b392b359
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -106,6 +106,7 @@ Domains,域名,
|
||||
Download,下载,
|
||||
Draft,草案,
|
||||
Due Date,到期日,
|
||||
Export,导出,
|
||||
Export as CSV,导出为CSV,
|
||||
Duration,持续时间,
|
||||
JERP Partner,JERP合作伙伴,
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user