feat(payment): separate creator income settlement

This commit is contained in:
2026-08-07 12:39:32 +08:00
parent cd93ea1656
commit e20418e909
17 changed files with 451 additions and 445 deletions
@@ -2,7 +2,7 @@
<PaymentSectionPage
section="revenue"
title="收益中心"
desc="查看收入流水、收益统计和提现记录。"
desc="查看现金与积分收入流水、收益统计和提现记录。"
:links="[
{ label: 'VIP 会员', path: '/payment/vip' },
{ label: '推广返佣', path: '/payment/affiliate' },
@@ -11,28 +11,10 @@
<template #before-form>
<n-card class="aksrt-section-card" title="收益统计">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<n-grid-item v-for="item in revenueSummary" :key="item.key">
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.total.toFixed(2) }}</strong>
<p>总收益 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.pending.toFixed(2) }}</strong>
<p>待结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.settled.toFixed(2) }}</strong>
<p>已结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.count }}</strong>
<p>收益笔数</p>
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</n-grid-item>
</n-grid>
@@ -47,24 +29,28 @@
</n-space>
</template>
<div v-if="timeSeries.length > 0" class="aksrt-revenue__chart">
<div class="aksrt-revenue__chart-bars">
<div
v-for="(item, index) in timeSeries"
:key="index"
class="aksrt-revenue__chart-bar-wrap"
:title="`${item.date}: ¥${item.amount.toFixed(2)} (${item.count}笔)`"
>
<div v-if="hasTimeSeries" class="aksrt-revenue__charts">
<div v-for="group in timeSeriesGroups" :key="group.key" class="aksrt-revenue__chart">
<h4>{{ group.label }}</h4>
<div v-if="group.items.length > 0" class="aksrt-revenue__chart-bars">
<div
class="aksrt-revenue__chart-bar"
:style="{ height: getBarHeight(item.amount) + '%' }"
/>
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
v-for="(item, index) in group.items"
:key="`${group.key}-${index}`"
class="aksrt-revenue__chart-bar-wrap"
:title="`${item.date}: ${formatAmount(item.amount, group.key)} (${item.count}笔)`"
>
<div
class="aksrt-revenue__chart-bar"
:style="{ height: getBarHeight(item.amount, group.items) + '%' }"
/>
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
</div>
</div>
<n-empty v-else size="small" description="暂无数据" />
<div class="aksrt-revenue__chart-summary">
<span>期间总计: <strong>{{ formatAmount(group.total, group.key) }}</strong></span>
<span>共 {{ group.count }} 笔</span>
</div>
</div>
<div class="aksrt-revenue__chart-summary">
<span>期间总计: <strong>¥{{ periodTotal.toFixed(2) }}</strong></span>
<span>共 {{ periodCount }} 笔</span>
</div>
</div>
<n-empty v-else description="暂无趋势数据" />
@@ -119,23 +105,53 @@ import { ref, computed, onMounted } from 'vue'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const revenueStats = ref({ total: 0, pending: 0, settled: 0, count: 0 })
const emptyCurrencyStats = () => ({ total: 0, pending: 0, settled: 0, count: 0 })
const revenueStats = ref({ cash: emptyCurrencyStats(), points: emptyCurrencyStats() })
const withdrawalStats = ref({ total: 0, pending: 0, processing: 0, completed: 0, rejected: 0, total_paid: 0 })
const timeSeries = ref([])
const timeSeries = ref({ cash: [], points: [] })
const granularity = ref('day')
const periodTotal = computed(() => timeSeries.value.reduce((s, i) => s + i.amount, 0))
const periodCount = computed(() => timeSeries.value.reduce((s, i) => s + i.count, 0))
const maxAmount = computed(() => Math.max(1, ...timeSeries.value.map(i => i.amount)))
const revenueSummary = computed(() => [
{ key: 'cash-total', value: `${revenueStats.value.cash.total.toFixed(2)} 元`, label: '现金总收益' },
{ key: 'cash-pending', value: `${revenueStats.value.cash.pending.toFixed(2)} 元`, label: '现金待结算' },
{ key: 'cash-settled', value: `${revenueStats.value.cash.settled.toFixed(2)} 元`, label: '现金已结算' },
{ key: 'cash-count', value: revenueStats.value.cash.count, label: '现金收益笔数' },
{ key: 'points-total', value: `${formatAmount(revenueStats.value.points.total, 'points')}`, label: '积分总收益' },
{ key: 'points-pending', value: `${formatAmount(revenueStats.value.points.pending, 'points')}`, label: '积分待结算' },
{ key: 'points-settled', value: `${formatAmount(revenueStats.value.points.settled, 'points')}`, label: '积分已结算' },
{ key: 'points-count', value: revenueStats.value.points.count, label: '积分收益笔数' },
])
function getBarHeight(amount) {
return Math.max(2, (amount / maxAmount.value) * 100)
const timeSeriesGroups = computed(() => [
{
key: 'cash',
label: '现金收益',
items: Array.isArray(timeSeries.value.cash) ? timeSeries.value.cash : [],
},
{
key: 'points',
label: '积分收益',
items: Array.isArray(timeSeries.value.points) ? timeSeries.value.points : [],
},
].map((group) => ({
...group,
total: group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0),
count: group.items.reduce((sum, item) => sum + Number(item.count || 0), 0),
})))
const hasTimeSeries = computed(() => timeSeriesGroups.value.some((group) => group.items.length > 0))
function formatAmount(amount, currency) {
return currency === 'points' ? `${Number(amount || 0).toFixed(0)} 积分` : `¥${Number(amount || 0).toFixed(2)}`
}
function getBarHeight(amount, items) {
const maxAmount = Math.max(1, ...items.map((item) => Number(item.amount || 0)))
return Math.max(2, (Number(amount || 0) / maxAmount) * 100)
}
function formatLabel(date) {
if (granularity.value === 'month') return date.slice(5)
if (granularity.value === 'week') return date.slice(5)
return date.slice(5)
return String(date || '').slice(5)
}
function setGranularity(nextGranularity) {
@@ -153,10 +169,8 @@ async function loadRevenue() {
if (res?.data) {
if (res.data.revenue) {
revenueStats.value = {
total: Number(res.data.revenue.total || 0),
pending: Number(res.data.revenue.pending || 0),
settled: Number(res.data.revenue.settled || 0),
count: Number(res.data.revenue.count || 0),
cash: { ...emptyCurrencyStats(), ...(res.data.revenue.cash || {}) },
points: { ...emptyCurrencyStats(), ...(res.data.revenue.points || {}) },
}
}
if (res.data.withdrawal) {
@@ -169,7 +183,10 @@ async function loadRevenue() {
total_paid: Number(res.data.withdrawal.total_paid || 0),
}
}
timeSeries.value = res.data.time_series || []
timeSeries.value = {
cash: res.data.time_series?.cash || [],
points: res.data.time_series?.points || [],
}
}
} catch {
// silent
@@ -200,10 +217,20 @@ onMounted(loadRevenue)
color: var(--color-ink-4, #8a909c);
}
.aksrt-revenue__charts {
display: grid;
gap: 24px;
}
.aksrt-revenue__chart {
min-height: 200px;
}
.aksrt-revenue__chart h4 {
margin: 0 0 10px;
color: var(--color-ink, #1a1a2e);
}
.aksrt-revenue__chart-bars {
display: flex;
align-items: flex-end;
@@ -2,46 +2,47 @@ export const revenueSchema = {
key: 'payment.revenue',
title: '创作分成',
icon: 'fas fa-chart-line',
description: '配置作者分成比例、结算门槛和结算周期。',
description: '分别配置现金收益和积分收益的会员分成比例,结算周期结束后自动入账。',
defaults: {
pay_income_s: false,
payment_revenue_share_ratio: 70,
ratio_vip_1: 0,
ratio_vip_2: 0,
payment_revenue_settlement_threshold: 100,
payment_revenue_cycle_days: 30,
payment_revenue_settlement_mode: 'automatic',
payment_revenue_settlement_delay_hours: 24,
payment_revenue_points_rule: '',
payment_revenue_note: '分成规则已就绪,订单入账后自动按比例结算。',
pay_income_desc_details: '',
pay_income_desc_page: '',
payment_revenue_note: '<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>',
payment_revenue_cash_ratio_user: 60,
payment_revenue_cash_ratio_vip_1: 70,
payment_revenue_cash_ratio_vip_2: 80,
payment_revenue_points_ratio_user: 0,
payment_revenue_points_ratio_vip_1: 0,
payment_revenue_points_ratio_vip_2: 0,
},
groups: [
{
key: 'policy',
title: '分成策略',
icon: 'fas fa-percentage',
key: 'basic',
title: '创作分成设置',
icon: 'fas fa-cog',
fields: [
{ key: 'pay_income_s', label: '启用创作分成结构', type: 'switch', default: false },
{ key: 'payment_revenue_share_ratio', label: '作者分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'ratio_vip_1', label: 'VIP1分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'ratio_vip_2', label: 'VIP2分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_threshold', label: '最小结算金额', type: 'number', default: 100, min: 0, max: 100000, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'pay_income_s', label: '启用创作分成', type: 'switch', default: false },
{ key: 'payment_revenue_cycle_days', label: '结算周期(天)', type: 'number', default: 30, min: 1, max: 365, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_mode', label: '结算模式', type: 'select', default: 'automatic', options: [{ value: 'automatic', label: '自动结算' }, { value: 'manual', label: '手动结算' }], dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_delay_hours', label: '结算延迟(小时)', type: 'number', default: 24, min: 0, max: 8760, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_rule', label: '积分规则', type: 'textarea', default: '', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_note', label: '结算备注', type: 'textarea', default: '分成策略和台账结构已就绪,订单入账后自动按比例结算。', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_note', label: '结算备注文案', type: 'textarea', sanitize_type: 'html', default: '<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>', help: '支持安全 HTML,结算后会展示在前台创作中心。', dependsOn: { key: 'pay_income_s', value: true } },
],
},
{
key: 'display',
title: '展示设置',
icon: 'fas fa-file-alt',
key: 'cash',
title: '现金收益分成',
icon: 'fas fa-wallet',
fields: [
{ key: 'pay_income_desc_details', label: '收益明细文案', type: 'textarea', default: '', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'pay_income_desc_page', label: '收益说明页', type: 'input', default: '', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cash_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 60, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cash_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cash_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 80, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
],
},
{
key: 'points',
title: '积分收益分成',
icon: 'fas fa-coins',
fields: [
{ key: 'payment_revenue_points_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
],
},
],