营销账户账单黑金优化与京东分账视图 #4

Merged
WuYongJie merged 2 commits from feature/WuYongJie-marketing-account-jd-ledgers into main 2026-08-04 13:06:57 +08:00
3 changed files with 427 additions and 66 deletions

View File

@ -1,25 +1,266 @@
<!-- 嵌入在业务页面中的表格视图(无完整布局包裹) -->
<style>
.bill-table-wrapper { overflow-x: auto; border-radius: 0.5rem; border: 1px solid #e5e7eb; }
.bill-table { min-width: 100%; white-space: nowrap; font-size: 0.875rem; }
.bill-table thead th { position: sticky; top: 0; background: #f9fafb; text-align: left; color: #6b7280; padding: 0.65rem 0.75rem; border-bottom: 1px solid #e5e7eb; font-weight: 600; }
.bill-table tbody td { padding: 0.65rem 0.75rem; border-bottom: 1px solid #f3f4f6; }
.bill-table tbody tr:hover { background: #f9fafb; }
.amount-cell { text-align: right; font-variant-numeric: tabular-nums; }
.marketing-bills-workspace {
color: #171717;
}
.marketing-bills-subtabs {
display: flex;
gap: 0.5rem;
margin: 0 0 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--vip-border);
}
.marketing-bills-subtab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 34px;
padding: 0 0.75rem;
border: 1px solid var(--vip-border);
border-radius: 0.5rem;
background: #ffffff;
color: #595959;
font-size: 0.8125rem;
font-weight: 500;
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease;
}
.marketing-bills-subtab:hover,
.marketing-bills-subtab.is-active {
border-color: var(--vip-content-accent-dark);
background: var(--vip-content-accent-soft);
color: var(--vip-content-accent-dark);
}
.marketing-bills-subtab:focus-visible,
.marketing-bills-primary:focus-visible,
.marketing-bills-secondary:focus-visible {
outline: 2px solid var(--vip-content-accent-dark);
outline-offset: 3px;
}
.marketing-bills-toolbar {
border-color: var(--vip-border);
}
.marketing-bills-primary,
.marketing-bills-secondary {
display: inline-flex;
min-height: 34px;
align-items: center;
gap: 0.5rem;
padding: 0 0.875rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease;
}
.marketing-bills-primary {
border: 1px solid var(--vip-content-accent-dark);
background: var(--vip-content-accent-dark);
color: #ffffff;
}
.marketing-bills-primary:hover {
border-color: #6A5129;
background: #6A5129;
color: #ffffff;
}
.marketing-bills-secondary {
border: 1px solid var(--vip-border);
background: #ffffff;
color: #171717;
}
.marketing-bills-secondary:hover {
border-color: var(--vip-content-accent-dark);
background: var(--vip-content-accent-soft);
color: var(--vip-content-accent-dark);
}
.marketing-bills-field-label {
color: #595959;
font-size: 0.75rem;
font-weight: 500;
}
.marketing-bills-file-input {
color: #595959;
font-size: 0.8125rem;
}
.marketing-bills-error {
border: 1px solid #B80000;
background: #fff5f5;
color: #B80000;
}
.marketing-bills-table-wrapper {
overflow-x: auto;
border: 1px solid var(--vip-border);
border-radius: 0.75rem;
}
.marketing-bills-table {
min-width: 100%;
white-space: nowrap;
font-size: 0.78125rem;
}
.marketing-bills-table thead th {
position: sticky;
top: 0;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--vip-border);
background: var(--vip-content-accent-soft);
color: #171717;
font-size: 0.75rem;
font-weight: 600;
text-align: left;
}
.marketing-bills-table tbody td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--vip-border);
color: #171717;
}
.marketing-bills-table tbody tr:last-child td {
border-bottom: 0;
}
.marketing-bills-table tbody tr:hover {
background: var(--vip-content-accent-soft);
}
.marketing-bills-checkbox {
border-color: var(--vip-border);
accent-color: var(--vip-content-accent-dark);
}
.marketing-bills-empty-state {
color: #595959;
}
.marketing-bills-amount {
text-align: right;
font-variant-numeric: tabular-nums;
}
.marketing-bills-edit {
color: var(--vip-content-accent-dark);
}
.marketing-bills-edit:hover {
color: #6A5129;
text-decoration: underline;
}
.marketing-bills-delete {
color: #B80000;
}
.marketing-bills-delete:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.marketing-bills-toolbar,
.marketing-bills-toolbar > div {
align-items: stretch;
}
.marketing-bills-toolbar .marketing-bills-primary,
.marketing-bills-toolbar .marketing-bills-secondary {
justify-content: center;
}
}
</style>
@php $pageAccountIds = $accounts->pluck('id')->map(fn($id) => (string) $id)->values(); @endphp
@php
$pageAccountIds = $accounts->pluck('id')->map(fn($id) => (string) $id)->values();
$isJdWallet = $platform === 'jd-wallet';
$jdLedger = $isJdWallet && request()->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge';
<div class="w-full" x-data="{ selected: [] }">
$columns = [
['field' => 'store_name', 'label' => trans('marketing_account_bills.store_name'), 'kind' => 'text'],
['field' => 'bill_date', 'label' => trans('marketing_account_bills.bill_date'), 'kind' => 'date'],
];
$rechargeColumns = [
['field' => 'total_deposit', 'label' => trans('marketing_account_bills.total_deposit'), 'kind' => 'money'],
['field' => 'total_transfer_in', 'label' => trans('marketing_account_bills.total_transfer_in'), 'kind' => 'money'],
['field' => 'total_transfer_out', 'label' => trans('marketing_account_bills.total_transfer_out'), 'kind' => 'money'],
];
$balanceColumns = [
['field' => 'daily_total_amount', 'label' => trans('marketing_account_bills.daily_total_amount'), 'kind' => 'money'],
['field' => 'period_end_balance', 'label' => trans('marketing_account_bills.period_end_balance'), 'kind' => 'money'],
['field' => 'grant_balance', 'label' => trans('marketing_account_bills.grant_balance'), 'kind' => 'money'],
['field' => 'non_grant_balance', 'label' => trans('marketing_account_bills.non_grant_balance'), 'kind' => 'money'],
];
$consumptionColumns = [
['field' => 'non_grant_consumption', 'label' => trans('marketing_account_bills.non_grant_consumption'), 'kind' => 'money'],
['field' => 'grant_consumption', 'label' => trans('marketing_account_bills.grant_consumption'), 'kind' => 'money'],
['field' => 'rebate_redpack_consumption', 'label' => trans('marketing_account_bills.rebate_redpack_consumption'), 'kind' => 'money'],
['field' => 'discount_redpack_consumption', 'label' => trans('marketing_account_bills.discount_redpack_consumption'), 'kind' => 'money'],
['field' => 'shared_wallet_consumption', 'label' => trans('marketing_account_bills.shared_wallet_consumption'), 'kind' => 'money'],
['field' => 'shared_grant_consumption', 'label' => trans('marketing_account_bills.shared_grant_consumption'), 'kind' => 'money'],
];
$consumptionSummaryColumns = [
['field' => 'daily_total_amount', 'label' => trans('marketing_account_bills.daily_total_amount'), 'kind' => 'money'],
['field' => 'period_end_balance', 'label' => trans('marketing_account_bills.period_end_balance'), 'kind' => 'money'],
];
$columns = array_merge(
$columns,
$isJdWallet
? ($jdLedger === 'consumption'
? array_merge($consumptionColumns, $consumptionSummaryColumns)
: array_merge($rechargeColumns, $balanceColumns))
: array_merge($rechargeColumns, $consumptionColumns, $balanceColumns),
[['field' => 'notes', 'label' => trans('marketing_account_bills.notes'), 'kind' => 'text']]
);
$tableColumnCount = count($columns) + 2;
@endphp
<div class="w-full marketing-bills-workspace" x-data="{ selected: [] }">
<form id="marketing-account-bills-export-form" method="GET"
action="{{ route('marketing-account-bills.export', $platform) }}"></form>
@if ($isJdWallet)
<nav class="marketing-bills-subtabs" aria-label="京东">
<a
href="{{ route('business.pages.show', ['section' => request()->route('section') ?? 'billing-transactions', 'page' => request()->route('page') ?? 'marketing-account-bills', 'tab' => 'jd-wallet', 'jd_ledger' => 'recharge']) }}"
@class(['marketing-bills-subtab', 'is-active' => $jdLedger === 'recharge'])
@if ($jdLedger === 'recharge') aria-current="page" @endif
>
<span class="material-icons-outlined text-base" aria-hidden="true">account_balance</span>
<span>充值账单</span>
</a>
<a
href="{{ route('business.pages.show', ['section' => request()->route('section') ?? 'billing-transactions', 'page' => request()->route('page') ?? 'marketing-account-bills', 'tab' => 'jd-wallet', 'jd_ledger' => 'consumption']) }}"
@class(['marketing-bills-subtab', 'is-active' => $jdLedger === 'consumption'])
@if ($jdLedger === 'consumption') aria-current="page" @endif
>
<span class="material-icons-outlined text-base" aria-hidden="true">receipt_long</span>
<span>消耗账单</span>
</a>
</nav>
@endif
{{-- 操作工具栏 --}}
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 border-b border-gray-200 pb-4">
<div class="marketing-bills-toolbar mb-4 flex flex-wrap items-end justify-between gap-3 border-b pb-4">
@can('create-banking-accounts')
<div class="flex flex-wrap items-end gap-3">
<a href="{{ route('marketing-account-bills.create', $platform) }}"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-green px-4 text-sm text-white hover:bg-green-700">
class="marketing-bills-primary">
<span class="material-icons text-base" aria-hidden="true">add</span>
{{ trans('marketing_account_bills.add_bill') }}
</a>
@ -27,21 +268,21 @@
enctype="multipart/form-data" class="flex flex-wrap items-end gap-3">
@csrf
<div>
<label for="bills-import-embedded" class="mb-1 block text-xs font-medium text-gray-600">
<label for="bills-import-embedded" class="marketing-bills-field-label mb-1 block">
{{ trans('marketing_account_bills.choose_file') }}
</label>
<input id="bills-import-embedded" type="file" name="import"
accept=".xlsx,.xls,.csv" required
class="block max-w-xs text-sm text-gray-600" />
accept=".xlsx,.xls,.csv" required
class="marketing-bills-file-input block max-w-xs" />
</div>
<button type="submit"
class="inline-flex h-10 items-center gap-2 rounded-lg bg-green px-4 text-sm text-white hover:bg-green-700">
class="marketing-bills-primary">
<span class="material-icons text-base" aria-hidden="true">upload_file</span>
{{ trans('marketing_account_bills.bulk_upload') }}
</button>
</form>
<a href="{{ route('marketing-account-bills.template', $platform) }}"
class="inline-flex h-10 items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 text-sm text-gray-700 hover:bg-gray-50">
class="marketing-bills-secondary">
<span class="material-icons text-base" aria-hidden="true">file_download</span>
{{ trans('marketing_account_bills.download_template') }}
</a>
@ -50,7 +291,7 @@
<div class="flex items-center gap-3">
@can('update-banking-accounts')
<button type="submit" form="marketing-account-bills-export-form"
class="inline-flex h-10 items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 text-sm text-gray-700 hover:bg-gray-50">
class="marketing-bills-secondary">
<span class="material-icons text-base" aria-hidden="true">download</span>
{{ trans('marketing_account_bills.bulk_download') }}
</button>
@ -59,49 +300,37 @@
</div>
@if ($errors->has('import'))
<div class="mb-4 border-l-4 border-red-500 bg-red-50 px-4 py-3 text-sm text-red-700">
<div class="marketing-bills-error mb-4 rounded-lg px-4 py-3 text-sm" role="alert">
@foreach ($errors->get('import') as $message)
<p>{{ $message }}</p>
@endforeach
</div>
@endif
{{-- 15列数据表格 --}}
<div class="bill-table-wrapper">
<table class="bill-table divide-y divide-gray-200">
<div class="marketing-bills-table-wrapper">
<table class="marketing-bills-table">
<thead>
<tr>
<th class="w-12 text-center">
<th scope="col" class="w-12 text-center">
<input type="checkbox"
aria-label="{{ trans('marketing_account_bills.select_all') }}"
@if ($pageAccountIds->isEmpty()) disabled @endif
x-on:change="selected = $event.target.checked ? @js($pageAccountIds) : []"
x-bind:checked="selected.length === {{ $pageAccountIds->count() }} && selected.length > 0"
class="h-4 w-4 rounded border-gray-300 text-green" />
class="marketing-bills-checkbox h-4 w-4 rounded" />
</th>
<th>{{ trans('marketing_account_bills.store_name') }}</th>
<th>{{ trans('marketing_account_bills.bill_date') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.total_deposit') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.total_transfer_in') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.total_transfer_out') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.non_grant_consumption') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.grant_consumption') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.rebate_redpack_consumption') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.discount_redpack_consumption') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.shared_wallet_consumption') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.shared_grant_consumption') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.daily_total_amount') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.period_end_balance') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.grant_balance') }}</th>
<th class="text-right">{{ trans('marketing_account_bills.non_grant_balance') }}</th>
<th>{{ trans('marketing_account_bills.notes') }}</th>
<th class="text-right">{{ trans('general.actions') }}</th>
@foreach ($columns as $column)
<th scope="col" @class(['marketing-bills-amount' => $column['kind'] === 'money'])>
{{ $column['label'] }}
</th>
@endforeach
<th scope="col" class="marketing-bills-amount">{{ trans('general.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tbody>
@if ($accounts->isEmpty())
<tr>
<td colspan="18" class="px-5 py-10 text-center text-gray-400">
<td colspan="{{ $tableColumnCount }}" class="marketing-bills-empty-state px-5 py-10 text-center">
{{ trans('marketing_account_bills.no_records') }}
</td>
</tr>
@ -113,30 +342,31 @@
form="marketing-account-bills-export-form"
x-model="selected"
aria-label="{{ trans('marketing_account_bills.select_bill') }}"
class="h-4 w-4 rounded border-gray-300 text-green" />
</td>
<td>{{ $account->store_name }}</td>
<td>{{ $account->bill_date->format('Y-m-d') }}</td>
<td class="amount-cell">{{ number_format($account->total_deposit, 2) }}</td>
<td class="amount-cell">{{ number_format($account->total_transfer_in, 2) }}</td>
<td class="amount-cell">{{ number_format($account->total_transfer_out, 2) }}</td>
<td class="amount-cell">{{ number_format($account->non_grant_consumption, 2) }}</td>
<td class="amount-cell">{{ number_format($account->grant_consumption, 2) }}</td>
<td class="amount-cell">{{ number_format($account->rebate_redpack_consumption, 2) }}</td>
<td class="amount-cell">{{ number_format($account->discount_redpack_consumption, 2) }}</td>
<td class="amount-cell">{{ number_format($account->shared_wallet_consumption, 2) }}</td>
<td class="amount-cell">{{ number_format($account->shared_grant_consumption, 2) }}</td>
<td class="amount-cell">{{ number_format($account->daily_total_amount, 2) }}</td>
<td class="amount-cell font-semibold">{{ number_format($account->period_end_balance, 2) }}</td>
<td class="amount-cell">{{ number_format($account->grant_balance, 2) }}</td>
<td class="amount-cell">{{ number_format($account->non_grant_balance, 2) }}</td>
<td class="max-w-xs truncate" title="{{ $account->notes }}">
{{ \Illuminate\Support\Str::limit($account->notes, 20) }}
class="marketing-bills-checkbox h-4 w-4 rounded" />
</td>
@foreach ($columns as $column)
<td
@class([
'marketing-bills-amount' => $column['kind'] === 'money',
'max-w-xs truncate' => $column['field'] === 'notes',
])
@if ($column['field'] === 'notes') title="{{ $account->notes }}" @endif
>
@if ($column['kind'] === 'date')
{{ $account->{$column['field']}?->format('Y-m-d') ?? '—' }}
@elseif ($column['kind'] === 'money')
{{ number_format((float) $account->{$column['field']}, 2) }}
@elseif ($column['field'] === 'notes')
{{ \Illuminate\Support\Str::limit((string) $account->{$column['field']}, 20) }}
@else
{{ $account->{$column['field']} }}
@endif
</td>
@endforeach
<td class="text-right whitespace-nowrap">
@can('update-banking-accounts')
<a href="{{ route('marketing-account-bills.edit', [$platform, $account]) }}"
class="text-purple hover:underline mr-3">{{ trans('general.edit') }}</a>
class="marketing-bills-edit mr-3">{{ trans('general.edit') }}</a>
@endcan
@can('delete-banking-accounts')
<form action="{{ route('marketing-account-bills.destroy', [$platform, $account]) }}"
@ -144,7 +374,7 @@
onsubmit="return confirm('{{ trans('general.delete_confirm') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">{{ trans('general.delete') }}</button>
<button type="submit" class="marketing-bills-delete">{{ trans('general.delete') }}</button>
</form>
@endcan
</td>

View File

@ -4,6 +4,20 @@
</x-slot>
<x-slot name="content">
@php
$isMarketingAccountBillsPage = ($section['slug'] ?? null) === 'billing-transactions'
&& ($page['slug'] ?? null) === 'marketing-account-bills';
$activeContentTitle = $activeTab['title'] ?? ($page['title'] ?? $section['title']);
if ($isMarketingAccountBillsPage && ($activeTab['slug'] ?? null) === 'taobao-wechat') {
$activeContentTitle = 'Alibaba';
}
if ($isMarketingAccountBillsPage && ($activeTab['slug'] ?? null) === 'jd-wallet') {
$activeContentTitle = '京东';
}
@endphp
<style>
#main-body > .container {
max-width: none;
@ -55,6 +69,39 @@
content: '';
}
.business-page--marketing-account-bills .business-tab-bar {
gap: 2rem;
border-color: var(--vip-border);
border-radius: 0.75rem;
box-shadow: none;
}
.business-page--marketing-account-bills .business-tab-link {
gap: 0.5rem;
color: #595959;
font-size: 0.875rem;
}
.business-page--marketing-account-bills .business-tab-link:hover,
.business-page--marketing-account-bills .business-tab-link.is-active {
color: var(--vip-content-accent-dark);
}
.business-page--marketing-account-bills .business-tab-link.is-active::after {
background: var(--vip-content-accent);
}
.business-page--marketing-account-bills .business-tab-link:focus-visible {
outline: 2px solid var(--vip-content-accent-dark);
outline-offset: 3px;
}
.business-page--marketing-account-bills .business-content-section {
border-color: var(--vip-border);
border-radius: 0.75rem;
box-shadow: none;
}
.biz-card {
background: #ffffff;
border: 1px solid #eef0f4;
@ -343,7 +390,10 @@
}
</style>
<div class="my-6">
<div @class([
'my-6',
'business-page--marketing-account-bills' => $isMarketingAccountBillsPage,
])>
@if (is_string(session('success')))
<div class="biz-alert" role="status">{{ session('success') }}</div>
@endif
@ -351,10 +401,21 @@
@if ($errors->has('import'))
<div class="biz-alert biz-alert-error" role="alert">{{ $errors->first('import') }}</div>
@endif
@if ($tabs->isNotEmpty())
<nav class="business-tab-bar" aria-label="{{ $page['title'] }}">
@foreach ($tabs as $tab)
@php
$visibleTabTitle = $tab['title'];
if ($isMarketingAccountBillsPage && $tab['slug'] === 'taobao-wechat') {
$visibleTabTitle = 'Alibaba';
}
if ($isMarketingAccountBillsPage && $tab['slug'] === 'jd-wallet') {
$visibleTabTitle = '京东';
}
@endphp
<a
href="{{ route('business.pages.show', [
'section' => $section['slug'],
@ -370,7 +431,7 @@
<span class="material-icons-outlined text-xl" aria-hidden="true">
{{ $tab['icon'] ?? 'description' }}
</span>
<span>{{ $tab['title'] }}</span>
<span>{{ $visibleTabTitle }}</span>
</a>
@endforeach
</nav>
@ -393,7 +454,7 @@
@endphp
@if (!empty($content) && !empty($content['view']))
<section class="business-content-section" aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}">
<section class="business-content-section" aria-label="{{ $activeContentTitle }}">
@include($content['view'], $content)
</section>
@elseif ($pageView)

View File

@ -0,0 +1,70 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Tests\Feature\FeatureTestCase;
class MarketingAccountBillsTabsTest extends FeatureTestCase
{
public function testMarketingAccountBillsOnlyRenamesTheRequestedTabs(): void
{
$this->allowMarketingAccountBillsPage();
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'billing-transactions',
'page' => 'marketing-account-bills',
'tab' => 'taobao-wechat',
]))
->assertOk()
->assertSeeText('Alibaba')
->assertSeeText('京东')
->assertSee('class="my-6 business-page--marketing-account-bills"', false);
}
public function testJdWalletHasRechargeAndConsumptionViewsWithoutChangingTheDataSource(): void
{
$this->allowMarketingAccountBillsPage();
$base = [
'section' => 'billing-transactions',
'page' => 'marketing-account-bills',
'tab' => 'jd-wallet',
];
$this->loginAs()
->get(route('business.pages.show', $base))
->assertOk()
->assertSeeText('充值账单')
->assertSeeText('消耗账单')
->assertSeeText('总存入金额(元)')
->assertDontSeeText('非赠款消耗金额(元)');
$this->loginAs()
->get(route('business.pages.show', array_merge($base, ['jd_ledger' => 'consumption'])))
->assertOk()
->assertSeeText('充值账单')
->assertSeeText('消耗账单')
->assertSeeText('非赠款消耗金额(元)')
->assertDontSeeText('总存入金额(元)');
}
private function allowMarketingAccountBillsPage(): void
{
$this->user->update(['locale' => 'zh-CN']);
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$subject = 'user_' . $this->user->id;
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [
$subject => ['billing-transactions/marketing-account-bills'],
],
], JSON_UNESCAPED_UNICODE));
}
}