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
v-if="showMessage"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700"
<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"
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>
</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();
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
v-if="showMessage"
class="mb-5 inline-flex gap-1.5 text-base text-gray-700"
<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"
>
<FeatherIcon class="h-4" name="info" />
<span> {{ $t('Please add billing details to your account before continuing.') }}</span>
</div>
<template #header>
<span class="text-lg font-semibold">{{ $t('Billing Details') }}</span>
</template>
<n-space vertical :size="20">
<n-alert
v-if="showMessage"
type="info"
:title="$t('Information')"
closable
>
{{ $t('Please add billing details to your account before continuing.') }}
</n-alert>
<BillingDetails
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);
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">
<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 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">
</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="text-gray-700">{{ $t('No address') }}</div>
<div v-else class="address-empty">
{{ $t('No address') }}
</div>
<div class="shrink-0">
<Button
:label="billingAddressButtonLabel"
</div>
<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>
</div>
</div>
</div>
{{ billingAddressButtonLabel }}
</n-button>
</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) {
@ -232,3 +250,62 @@ function updatePaymentMode(mode) {
if (!changePaymentMode.loading) changePaymentMode.submit({ mode });
}
</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"
<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"
>
<i-lucide-alert-triangle class="mx-auto mb-4 h-6 w-6 text-red-600" />
<ErrorMessage :message="$t('You do not have permission to view the billing page')" />
<n-tab-pane
v-for="tab in filteredTabs"
:key="tab.route.name"
:name="tab.route.name"
:tab="tab.label"
>
<router-view :tab="tab" />
</n-tab-pane>
</n-tabs>
</div>
<div v-else class="no-permission-container">
<n-result
status="403"
:title="$t('Access Denied')"
:description="$t('You do not have permission to view the billing page')"
>
<template #icon>
<i-lucide-alert-triangle class="h-12 w-12 text-red-600" />
</template>
</n-result>
</div>
</div>
</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,
},
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' } },
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: this.$t('Developer Earnings'),
label: $t('Developer Earnings'),
route: { name: 'BillingMarketplacePayouts' },
requireDeveloper: true,
requirePro: true
},
],
};
},
computed: {
filteredTabs() {
return this.tabs.filter(tab => {
];
const filteredTabs = computed(() => {
return tabs.filter(tab => {
//
if (tab.requireDeveloper && !this.$team.pg.is_developer) {
if (tab.requireDeveloper && !$team.pg.is_developer) {
return false;
}
//
if (tab.requirePro && !this.$team.pg.is_pro) {
if (tab.requirePro && !$team.pg.is_pro) {
return false;
}
return true;
});
}
});
const activeTab = computed({
get() {
return route.name || 'BillingOverview';
},
};
set(value) {
router.push({ name: value });
}
});
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.billing-container {
width: 100%;
display: flex;
flex-direction: column;
}
.billing-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.billing-tabs {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.n-tabs-nav) {
padding: 0 24px;
background: white;
border-bottom: 1px solid var(--n-border-color);
}
:deep(.n-tabs-tab) {
padding: 12px 16px;
font-size: 15px;
font-weight: 500;
}
:deep(.n-tabs-tab--active) {
color: var(--n-color);
}
:deep(.n-tab-pane) {
padding: 0;
overflow-y: auto;
}
.no-permission-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 24px;
}
@media (max-width: 768px) {
:deep(.n-tabs-nav) {
padding: 0 16px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
:deep(.n-tabs-tab) {
padding: 10px 12px;
font-size: 14px;
white-space: nowrap;
}
:deep(.n-tabs-nav-scroll-content) {
display: flex;
}
}
</style>

View File

@ -1,44 +1,50 @@
<template>
<div class="p-5">
<div class="billing-balances-container">
<n-card class="balances-card">
<ObjectList :options="options">
<template #header-right>
<Button
icon="download"
appearance="primary"
@click="exportToCsv"
<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') }}
</Button>
</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() {
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const $team = instance?.appContext.config.globalProperties.$team;
const exporting = ref(false);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const props = defineProps(['tab']);
const options = computed(() => {
return {
pagetype: 'Balance Transaction',
fields: ['type', 'source', 'invoice', 'description'],
columns: [
{
label: this.$t('Time'),
label: $t('Time'),
fieldname: 'creation',
format(value) {
return new Date(value).toLocaleString('zh-CN', {
@ -52,7 +58,7 @@ export default {
}
},
{
label: this.$t('Description'),
label: $t('Description'),
fieldname: 'description',
format(value, row) {
if (value !== null && value !== undefined) {
@ -60,57 +66,57 @@ export default {
}
if (row.type === 'Applied To Invoice' && row.invoice) {
return this.$t('Applied to Invoice {invoice}', { invoice: row.invoice });
return $t('Applied to Invoice {invoice}', { invoice: row.invoice });
}
if (row.source === 'Prepaid Credits') {
return this.$t('Balance Recharge');
return $t('Balance Recharge');
}
if (row.source === 'Free Credits') {
return this.$t('Free Credits');
return $t('Free Credits');
}
return row.amount < 0 ? row.type : row.source;
}
},
{
label: this.$t('Amount'),
label: $t('Amount'),
fieldname: 'amount',
align: 'right',
format: this.formatCurrency
format: formatCurrency
},
{
label: this.$t('Balance'),
label: $t('Balance'),
fieldname: 'ending_balance',
align: 'right',
format: this.formatCurrency
format: formatCurrency
}
],
filters: {
pagestatus: 1,
team: this.$team.name
team: $team.name
},
orderBy: 'creation desc'
};
}
},
created() {
//
this.exportResource = createResource({
});
//
const exportResource = createResource({
url: 'jcloud.api.billing.get_balance_transactions',
onSuccess: this.handleExportSuccess,
onError: this.handleExportError
});
},
methods: {
formatCurrency(value) {
onSuccess: handleExportSuccess,
onError: handleExportError
});
function formatCurrency(value) {
if (value === 0) {
return '';
}
return this.$format.userCurrency(value);
},
formatDate(dateString) {
const $format = instance?.appContext.config.globalProperties.$format;
return $format?.userCurrency(value) || value;
}
function formatDate(dateString) {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
@ -119,23 +125,25 @@ export default {
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
},
exportToCsv() {
this.exporting = true;
this.exportResource.submit({
}
function exportToCsv() {
exporting.value = true;
exportResource.submit({
page: 1,
page_size: 1000
});
},
handleExportSuccess(response) {
}
function handleExportSuccess(response) {
const transactions = response.transactions || [];
// CSV
const fields = [
this.$t('Time'),
this.$t('Description'),
this.$t('Amount'),
this.$t('Balance')
$t('Time'),
$t('Description'),
$t('Amount'),
$t('Balance')
];
//
@ -146,18 +154,18 @@ export default {
// 使
if (!description) {
if (row.type === 'Applied To Invoice' && row.invoice) {
description = this.$t('Applied to Invoice {invoice}', { invoice: row.invoice });
description = $t('Applied to Invoice {invoice}', { invoice: row.invoice });
} else if (row.source === 'Prepaid Credits') {
description = this.$t('Balance Recharge');
description = $t('Balance Recharge');
} else if (row.source === 'Free Credits') {
description = this.$t('Free Credits');
description = $t('Free Credits');
} else {
description = row.amount < 0 ? row.type : row.source;
}
}
return [
this.formatDate(row.creation),
formatDate(row.creation),
description,
row.amount,
row.ending_balance
@ -176,30 +184,50 @@ export default {
//
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 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);
this.exporting = false;
},
handleExportError(error) {
console.error(this.$t('Failed to export data'), error);
this.exporting = false;
}
}
};
</script>
<style scoped>
/* 添加悬浮效果 */
.flex-col:hover {
background-color: #f0f0f0; /* 可按需调整颜色 */
exporting.value = false;
}
/* 允许内容复制 */
.flex-col * {
user-select: text;
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,43 +1,53 @@
<template>
<div class="p-5">
<div class="billing-marketplace-payouts-container">
<n-card class="payouts-card">
<ObjectList :options="options" />
<Dialog
v-model="payoutDialog"
:options="{ size: '6xl', title: showPayout?.name }"
</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 #header>
<span class="text-lg font-semibold">{{ showPayout?.name }}</span>
</template>
<template v-if="showPayout">
<div
<n-empty
v-if="showPayout.status === 'Empty'"
class="text-base text-gray-600"
>
{{ $t('No content to display') }}
</div>
:description="$t('No content to display')"
/>
<PayoutTable v-else :payoutId="showPayout.name" />
</template>
</template>
</Dialog>
</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() {
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const $team = instance?.appContext.config.globalProperties.$team;
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: [
@ -51,14 +61,14 @@ export default {
return [
{
type: 'select',
label: this.$t('Status'),
class: !this.$isMobile ? 'w-36' : '',
label: $t('Status'),
class: !isMobile.value ? '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' }
{ label: $t('Pending Settlement'), value: 'Draft' },
{ label: $t('Paid'), value: 'Paid' },
{ label: $t('Settled'), value: 'Commissioned' }
]
}
];
@ -66,7 +76,7 @@ export default {
orderBy: 'creation desc',
columns: [
{
label: this.$t('Date'),
label: $t('Date'),
fieldname: 'period_end',
format(value) {
return Intl.DateTimeFormat('en-US', {
@ -76,30 +86,86 @@ export default {
}).format(new Date(value));
}
},
{ label: this.$t('Payment Method'), fieldname: 'mode_of_payment' },
{ label: this.$t('Status'), fieldname: 'status', type: 'Badge' },
{ label: $t('Payment Method'), fieldname: 'mode_of_payment' },
{ label: $t('Status'), fieldname: 'status', type: 'Badge' },
{
label: this.$t('Total'),
label: $t('Total'),
fieldname: 'net_total_cny',
align: 'right',
format: (_, row) => {
let total = 0;
if (this.$team.pg.currency === 'CNY') {
const $format = instance?.appContext.config.globalProperties.$format;
if ($team.pg.currency === 'CNY') {
total = row.net_total_cny + row.net_total_usd * 82;
} else {
total = row.net_total_cny / 82 + row.net_total_usd;
}
return this.$format.userCurrency(total);
return $format?.userCurrency(total) || total;
}
}
],
onRowClick: row => {
this.showPayout = row;
this.payoutDialog = true;
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;
}
: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"
<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...')"
type="text"
class="w-60"
clearable
:size="inputSize"
class="search-input"
@input="debouncedSearch"
>
<template #prefix>
<i-lucide-search class="h-4 w-4 text-gray-500" />
<i-lucide-search class="h-4 w-4" />
</template>
</FInput>
</div>
<div class="flex items-center space-x-2">
<Button
icon="download"
appearance="primary"
</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') }}
</Button>
<Button
icon="refresh-cw"
appearance="minimal"
</n-button>
<n-button
:size="buttonSize"
@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>
:loading="initialLoading"
:block="isMobile"
>
<template #icon>
<i-lucide-refresh-cw class="h-4 w-4" />
</template>
</n-button>
</n-space>
</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"
<n-empty
v-if="!initialLoading && orders.length === 0"
:description="$t('No order records to display')"
class="empty-state"
>
{{ 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>
<template #icon>
<i-lucide-file-text class="h-12 w-12 text-gray-400" />
</template>
</n-empty>
<div class="flex items-center justify-between p-4">
<div class="text-sm text-gray-600">
<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>
<Button
<n-button
v-if="hasMoreToLoad"
@click="loadMore"
type="primary"
:loading="loadingMore"
appearance="primary"
:size="buttonSize"
:block="isMobile"
@click="loadMore"
>
{{ $t('Load More') }}
</Button>
</n-button>
</div>
</template>
</Card>
</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 filters = reactive({
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 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 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
];
//
const hasMoreToLoad = computed(() => {
return orders.value.length < totalCount.value;
});
//
if (isMobile.value) {
return baseColumns.filter(col =>
['creation', 'title', 'total', 'status'].includes(col.key)
);
}
//
const ordersResource = createResource({
return baseColumns;
});
//
const ordersResource = createResource({
url: 'jcloud.api.billing.get_orders',
transform(response) {
return {
@ -169,10 +246,10 @@ export default {
initialLoading.value = false;
loadingMore.value = false;
}
});
});
//
const exportResource = createResource({
//
const exportResource = createResource({
url: 'jcloud.api.billing.get_orders',
onSuccess(response) {
const orders = response.orders || [];
@ -221,14 +298,15 @@ export default {
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
exporting.value = false;
},
onError(error) {
//
exporting.value = false;
}
});
});
//
function fetchOrders() {
//
function fetchOrders() {
if (currentPage.value === 1) {
initialLoading.value = true;
} else {
@ -240,52 +318,53 @@ export default {
page_size: pageSize,
search: filters.search,
});
}
}
//
function resetAndFetch() {
//
function resetAndFetch() {
currentPage.value = 1;
fetchOrders();
}
}
//
function loadMore() {
//
function loadMore() {
if (loadingMore.value) return;
currentPage.value += 1;
loadingMore.value = true;
fetchOrders();
}
}
//
let searchTimeout;
function debouncedSearch() {
//
let searchTimeout;
function debouncedSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
resetAndFetch();
}, 300);
}
}
// CSV
function exportToCsv() {
// CSV
function exportToCsv() {
exporting.value = true;
exportResource.submit({
page: 1,
page_size: 1000, //
search: filters.search,
});
}
}
//
function formatCurrency(amount) {
//
function formatCurrency(amount) {
if (amount === undefined || amount === null) return '-';
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(amount);
}
}
//
function formatDate(dateString) {
//
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
@ -299,10 +378,10 @@ export default {
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
}
//
function getStatusProps(status) {
//
function getStatusProps(status) {
const statusMap = {
'待支付': { label: $t('Pending Payment'), color: 'orange' },
'已支付': { label: $t('Paid'), color: 'green' },
@ -312,31 +391,98 @@ export default {
};
return statusMap[status] || { label: status || '', color: 'blue' };
}
}
//
onMounted(() => {
function handleResize() {
windowWidth.value = window.innerWidth;
}
//
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
fetchOrders();
});
});
return {
columns,
orders,
totalCount,
currentPage,
initialLoading,
loadingMore,
hasMoreToLoad,
filters,
fetchOrders,
resetAndFetch,
loadMore,
debouncedSearch,
exportToCsv,
formatCurrency,
formatDate,
getStatusProps
};
}
};
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;
}
.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"
>
<div class="billing-overview-container">
<div v-if="team.pg" class="billing-overview-content">
<PaymentDetails />
</div>
<div v-else class="mt-12 flex flex-1 items-center justify-center">
<Spinner class="h-8" />
<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');
@ -38,3 +38,30 @@ provide('billing', {
unpaidInvoices
});
</script>
<style scoped>
.billing-overview-container {
width: 100%;
min-height: 100%;
}
.billing-overview-content {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 24px;
}
@media (max-width: 768px) {
.billing-overview-content {
padding: 16px;
}
}
</style>

View File

@ -1,23 +1,25 @@
<template>
<div class="p-5">
<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() {
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: [
@ -28,21 +30,21 @@ export default {
'brand',
'stripe_mandate_id'
],
emptyStateMessage: this.$t('No cards added'),
emptyStateMessage: $t('No cards added'),
columns: [
{
label: this.$t('Card Name'),
label: $t('Card Name'),
fieldname: 'name_on_card'
},
{
label: this.$t('Card'),
label: $t('Card'),
fieldname: 'last_4',
width: 1.5,
format(value) {
return `•••• ${value}`;
},
prefix: row => {
return this.cardBrandIcon(row.brand);
return cardBrandIcon(row.brand);
},
suffix(row) {
if (row.is_default) {
@ -51,20 +53,20 @@ export default {
{
theme: 'green'
},
() => this.$t('Default')
() => $t('Default')
);
}
}
},
{
label: this.$t('Expiry Date'),
label: $t('Expiry Date'),
width: 0.5,
format(value, row) {
return `${row.expiry_month}/${row.expiry_year}`;
}
},
{
label: this.$t('Authorization'),
label: $t('Authorization'),
type: 'Component',
width: 1,
align: 'center',
@ -86,7 +88,7 @@ export default {
return h(
Tooltip,
{
text: this.$t('This card failed to pay last time. Please use another card.')
text: $t('This card failed to pay last time. Please use another card.')
},
() =>
h(FeatherIcon, {
@ -107,7 +109,7 @@ export default {
rowActions: ({ listResource, row }) => {
return [
{
label: this.$t('Set as Default'),
label: $t('Set as Default'),
onClick: () => {
toast.promise(
listResource.runDocMethod.submit({
@ -115,24 +117,24 @@ export default {
name: row.name
}),
{
loading: this.$t('Setting as default...'),
success: this.$t('Default card set'),
error: this.$t('Failed to set default card')
loading: $t('Setting as default...'),
success: $t('Default card set'),
error: $t('Failed to set default card')
}
);
},
condition: () => !row.is_default
},
{
label: this.$t('Remove'),
label: $t('Remove'),
onClick: () => {
if (row.is_default && this.$team.pg.payment_mode === 'Card') {
toast.error(this.$t('Cannot remove default card'));
if (row.is_default && $team.pg.payment_mode === 'Card') {
toast.error($t('Cannot remove default card'));
return;
}
confirmDialog({
title: this.$t('Remove Card'),
message: this.$t('Are you sure you want to remove this card?'),
title: $t('Remove Card'),
message: $t('Are you sure you want to remove this card?'),
onSuccess: ({ hide }) => {
toast.promise(
listResource.delete.submit(row.name, {
@ -141,12 +143,12 @@ export default {
}
}),
{
loading: this.$t('Removing card...'),
success: this.$t('Card removed'),
loading: $t('Removing card...'),
success: $t('Card removed'),
error: error =>
error.messages?.length
? error.messages.join('\n')
: error.message || this.$t('Failed to remove card')
: error.message || $t('Failed to remove card')
}
);
}
@ -158,7 +160,7 @@ export default {
orderBy: 'creation desc',
primaryAction() {
return {
label: this.$t('Add Card'),
label: $t('Add Card'),
slots: {
prefix: icon('plus')
},
@ -171,16 +173,9 @@ export default {
};
}
};
}
},
methods: {
formatCurrency(value) {
if (value === 0) {
return '';
}
return this.$format.userCurrency(value);
},
cardBrandIcon(brand) {
});
function cardBrandIcon(brand) {
let component = {
'master-card': defineAsyncComponent(() =>
import('@/components/icons/cards/MasterCard.vue')
@ -203,7 +198,24 @@ formatCurrency(value) {
}[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;
}
}
</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合作伙伴