feat: add withdrawal table controls #258

Merged
yutu merged 1 commits from feature/92451-withdrawal-table-controls into main 2026-09-23 22:02:03 +08:00
5 changed files with 450 additions and 66 deletions

View File

@ -9,6 +9,20 @@ use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
class WithdrawalManagementReport
{
private const SORT_FIELDS = [
'platform',
'erp_store_name',
'store_id',
'store_entity',
'withdrawal_path',
'withdrawal_account',
'bank_card',
'balance',
'reserve_standard',
'withdrawal_amount',
'compliance',
];
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
@ -20,12 +34,13 @@ class WithdrawalManagementReport
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
* balanceColumnLabel:string,balanceDate:string
* balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string}
* }
*/
public function results(Request $request): array
{
$filters = $this->filters($request);
$sort = $this->sorting($request);
$platforms = $this->options('store_platform');
$paths = $this->options('receiving_method');
$storeEntities = $this->options('store_entity_name');
@ -58,18 +73,6 @@ class WithdrawalManagementReport
);
}
$statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
$rows = $rows->sort(
static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($balances, $statusOrder): int {
$leftStatus = $balances[(int) $left->id]['status'] ?? 'not_ready';
$rightStatus = $balances[(int) $right->id]['status'] ?? 'not_ready';
$statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
return $statusComparison !== 0
? $statusComparison
: ((int) $right->id <=> (int) $left->id);
},
)->values();
$reserveStandards = $rows->mapWithKeys(
static fn (StoreReceivingAccount $row): array => [
(int) $row->id => WithdrawalReserveStandard::amountFor(
@ -78,6 +81,9 @@ class WithdrawalManagementReport
),
],
)->all();
$rows = $sort['field'] === ''
? $this->sortByBalanceStatus($rows, $balances)
: $this->sortRows($rows, $sort, $balances, $reserveStandards);
return [
'filters' => $filters,
@ -96,6 +102,7 @@ class WithdrawalManagementReport
],
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
'balanceDate' => $balanceDate,
'sort' => $sort,
];
}
@ -131,4 +138,95 @@ class WithdrawalManagementReport
->values()
->all();
}
/** @return array{field:string,direction:string} */
private function sorting(Request $request): array
{
$field = trim((string) $request->query('sort'));
$direction = strtolower(trim((string) $request->query('direction')));
return [
'field' => in_array($field, self::SORT_FIELDS, true) ? $field : '',
'direction' => in_array($direction, ['asc', 'desc'], true) ? $direction : 'asc',
];
}
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @return Collection<int,StoreReceivingAccount>
*/
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
{
$statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
return $rows->sort(
static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($balances, $statusOrder): int {
$leftStatus = $balances[(int) $left->id]['status'] ?? 'not_ready';
$rightStatus = $balances[(int) $right->id]['status'] ?? 'not_ready';
$statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
return $statusComparison !== 0
? $statusComparison
: ((int) $right->id <=> (int) $left->id);
},
)->values();
}
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array{field:string,direction:string} $sort
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,int|null> $reserveStandards
* @return Collection<int,StoreReceivingAccount>
*/
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards): Collection
{
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards): int {
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards);
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards);
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
return $comparison !== 0
? $comparison
: ((int) $right->id <=> (int) $left->id);
})->values();
}
/**
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,int|null> $reserveStandards
*/
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards): mixed
{
return match ($field) {
'platform' => $row->store_platform,
'erp_store_name' => $row->erp_store_name,
'store_id' => $row->store_id,
'store_entity' => $row->store_entity_name,
'withdrawal_path' => $row->withdrawal_path,
'withdrawal_account' => $row->withdrawal_account_name,
'bank_card' => $row->bank_card_last_four,
'balance' => ($balances[(int) $row->id]['status'] ?? null) === 'matched'
? $balances[(int) $row->id]['balance']
: null,
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
default => null,
};
}
private function compareSortValues(mixed $left, mixed $right, string $direction): int
{
$leftEmpty = $left === null || $left === '';
$rightEmpty = $right === null || $right === '';
if ($leftEmpty || $rightEmpty) {
return $leftEmpty === $rightEmpty ? 0 : ($leftEmpty ? 1 : -1);
}
$comparison = is_numeric($left) && is_numeric($right)
? ((float) $left <=> (float) $right)
: strnatcasecmp((string) $left, (string) $right);
return $direction === 'desc' ? -$comparison : $comparison;
}
}

View File

@ -894,6 +894,7 @@ class Pages extends Controller
'withdrawalBalanceSummary' => $report['summary'],
'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'],
'withdrawalBalanceDate' => $report['balanceDate'],
'withdrawalSort' => $report['sort'],
];
}

View File

@ -1,28 +1,69 @@
@php
$withdrawalImportPreview = session('withdrawalImportPreview');
$withdrawalOpenImport = is_array($withdrawalImportPreview) || $errors->hasAny(['import', 'preview_token']);
$withdrawalQuery = array_filter(
$withdrawalFilterQuery = array_filter(
request()->only(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']),
static fn ($value): bool => filled($value),
);
$withdrawalQuery = array_filter(
request()->only(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status', 'sort', 'direction']),
static fn ($value): bool => filled($value),
);
$withdrawalSortUrl = static function (string $field, string $direction) use ($withdrawalFilterQuery): string {
$query = http_build_query(array_merge($withdrawalFilterQuery, [
'sort' => $field,
'direction' => $direction,
]));
return request()->url() . '?' . $query;
};
$withdrawalColumns = [
['key' => 'platform', 'label' => '店铺平台', 'numeric' => false, 'title' => null],
['key' => 'erp_store_name', 'label' => 'ERP店铺名称', 'numeric' => false, 'title' => null],
['key' => 'store_id', 'label' => '平台店铺ID', 'numeric' => false, 'title' => null],
['key' => 'store_entity', 'label' => '店铺主体', 'numeric' => false, 'title' => null],
['key' => 'withdrawal_path', 'label' => '提现路径', 'numeric' => false, 'title' => null],
['key' => 'withdrawal_account', 'label' => '提现账户名称', 'numeric' => false, 'title' => null],
['key' => 'bank_card', 'label' => '银行卡号', 'numeric' => false, 'title' => null],
['key' => 'balance', 'label' => $withdrawalBalanceColumnLabel, 'numeric' => true, 'title' => '余额数据日期:' . $withdrawalBalanceDate],
['key' => 'reserve_standard', 'label' => '预留标准', 'numeric' => true, 'title' => null],
['key' => 'withdrawal_amount', 'label' => '提现金额', 'numeric' => true, 'title' => null],
['key' => 'compliance', 'label' => '是否达标', 'numeric' => false, 'title' => null],
];
@endphp
<section class="biz-card mt-6 withdrawal-management" aria-label="提现配置列表">
<div class="biz-card-head">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">account_balance_wallet</span>
<span>提现配置</span>
<div class="withdrawal-management__heading">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">account_balance_wallet</span>
<span>提现配置</span>
</div>
<div class="withdrawal-management__summary">
<span class="biz-badge">余额源 {{ number_format($withdrawalBalanceSummary['source']) }} 条</span>
<span class="biz-badge biz-badge-yes">已显示余额 {{ number_format($withdrawalBalanceSummary['matched']) }} 条</span>
@if ($withdrawalBalanceSummary['ambiguous'] > 0)
<span class="biz-badge biz-badge-no">多条匹配 {{ number_format($withdrawalBalanceSummary['ambiguous']) }} 条</span>
@endif
</div>
</div>
<div>
<span class="biz-badge">余额源 {{ number_format($withdrawalBalanceSummary['source']) }} 条</span>
<span class="biz-badge biz-badge-yes">已显示余额 {{ number_format($withdrawalBalanceSummary['matched']) }} 条</span>
@if ($withdrawalBalanceSummary['ambiguous'] > 0)
<span class="biz-badge biz-badge-no">多条匹配 {{ number_format($withdrawalBalanceSummary['ambiguous']) }} 条</span>
@endif
<div class="withdrawal-management__actions" aria-label="提现管理导入导出">
<button type="button" class="biz-btn biz-btn-ghost" data-withdrawal-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span>导入</span>
</button>
<a class="biz-btn biz-btn-primary" href="{{ route('business.withdrawal-management.export', $withdrawalQuery) }}">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出</span>
</a>
</div>
</div>
<form class="biz-filter-bar" method="GET" action="{{ request()->url() }}" aria-label="提现管理筛选">
@if ($withdrawalSort['field'] !== '')
<input type="hidden" name="sort" value="{{ $withdrawalSort['field'] }}">
<input type="hidden" name="direction" value="{{ $withdrawalSort['direction'] }}">
@endif
<div class="biz-filter-field">
<label for="withdrawal-platform">店铺平台</label>
<select id="withdrawal-platform" class="biz-filter-select" name="platform">
@ -65,34 +106,49 @@
</select>
</div>
<button type="submit" class="biz-btn biz-btn-primary">筛选</button>
<button type="button" class="biz-btn biz-btn-ghost" data-withdrawal-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span>导入</span>
</button>
<a class="biz-btn biz-btn-ghost" href="{{ route('business.withdrawal-management.export', $withdrawalQuery) }}">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出</span>
</a>
@if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']))
<a class="biz-btn biz-btn-ghost" href="{{ request()->url() }}">重置</a>
@endif
</form>
<div class="biz-table-wrap">
<table class="biz-table" data-finance-table-name="withdrawal-management-v3">
<table
class="biz-table"
aria-label="提现管理表格"
data-finance-table-name="withdrawal-management-v4"
data-finance-table-native
data-withdrawal-table
data-column-storage-key="caiwu.withdrawal.columns.v1.{{ auth()->id() ?? 'guest' }}"
>
<thead>
<tr>
<th>店铺平台</th>
<th>ERP店铺名称</th>
<th>平台店铺ID</th>
<th>店铺主体</th>
<th>提现路径</th>
<th>提现账户名称</th>
<th>银行卡号</th>
<th class="num" title="余额数据日期:{{ $withdrawalBalanceDate }}">{{ $withdrawalBalanceColumnLabel }}</th>
<th class="num">预留标准</th>
<th class="num">提现金额</th>
<th>是否达标</th>
@foreach ($withdrawalColumns as $column)
<th
@class(['num' => $column['numeric']])
data-withdrawal-column="{{ $column['key'] }}"
@if ($column['title']) title="{{ $column['title'] }}" @endif
@if ($withdrawalSort['field'] === $column['key']) aria-sort="{{ $withdrawalSort['direction'] === 'asc' ? 'ascending' : 'descending' }}" @endif
>
<div class="withdrawal-table-head">
<span>{{ $column['label'] }}</span>
<button
type="button"
class="withdrawal-table-head__menu"
data-withdrawal-column-trigger
data-sort-field="{{ $column['key'] }}"
data-sort-label="{{ $column['label'] }}"
data-sort-asc="{{ $withdrawalSortUrl($column['key'], 'asc') }}"
data-sort-desc="{{ $withdrawalSortUrl($column['key'], 'desc') }}"
aria-label="设置{{ $column['label'] }}排序和显示字段"
aria-haspopup="dialog"
aria-expanded="false"
title="排序和显示字段"
>
<span class="material-icons-outlined" aria-hidden="true">{{ $withdrawalSort['field'] === $column['key'] ? ($withdrawalSort['direction'] === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}</span>
</button>
</div>
</th>
@endforeach
</tr>
</thead>
<tbody>
@ -114,17 +170,17 @@
$reserveStandard = $withdrawalReserveStandards[(int) $row->id] ?? null;
@endphp
<tr>
<td>{{ $row->store_platform ?: '未配置' }}</td>
<td>{{ $row->erp_store_name ?: '-' }}</td>
<td>{{ $row->store_id ?: '-' }}</td>
<td>{{ $row->store_entity_name ?: '-' }}</td>
<td>{{ $row->withdrawal_path ?: '未配置' }}</td>
<td>{{ $row->withdrawal_account_name ?: '未配置' }}</td>
<td>{{ $bankCard }}</td>
<td class="num" title="{{ $balanceTitle }}">{{ $balanceText }}</td>
<td class="num">{{ $reserveStandard === null ? '待配置' : number_format($reserveStandard, 2) }}</td>
<td class="num">待接入</td>
<td><span class="biz-badge biz-badge-no">待核验</span></td>
<td data-withdrawal-column="platform">{{ $row->store_platform ?: '未配置' }}</td>
<td data-withdrawal-column="erp_store_name">{{ $row->erp_store_name ?: '-' }}</td>
<td data-withdrawal-column="store_id">{{ $row->store_id ?: '-' }}</td>
<td data-withdrawal-column="store_entity">{{ $row->store_entity_name ?: '-' }}</td>
<td data-withdrawal-column="withdrawal_path">{{ $row->withdrawal_path ?: '未配置' }}</td>
<td data-withdrawal-column="withdrawal_account">{{ $row->withdrawal_account_name ?: '未配置' }}</td>
<td data-withdrawal-column="bank_card">{{ $bankCard }}</td>
<td class="num" data-withdrawal-column="balance" title="{{ $balanceTitle }}">{{ $balanceText }}</td>
<td class="num" data-withdrawal-column="reserve_standard">{{ $reserveStandard === null ? '待配置' : number_format($reserveStandard, 2) }}</td>
<td class="num" data-withdrawal-column="withdrawal_amount">待接入</td>
<td data-withdrawal-column="compliance"><span class="biz-badge biz-badge-no">待核验</span></td>
</tr>
@empty
<tr>
@ -138,6 +194,42 @@
@endforelse
</tbody>
</table>
<div
class="withdrawal-column-menu"
data-withdrawal-column-menu
data-current-sort="{{ $withdrawalSort['field'] }}"
data-current-direction="{{ $withdrawalSort['direction'] }}"
role="dialog"
aria-label="表格排序和显示字段"
hidden
>
<div class="withdrawal-column-menu__title" data-withdrawal-menu-title>表格设置</div>
<div class="withdrawal-column-menu__sort" aria-label="排序方式">
<a href="#" data-withdrawal-sort-asc>
<span class="material-icons-outlined" aria-hidden="true">arrow_upward</span>
<span>升序</span>
</a>
<a href="#" data-withdrawal-sort-desc>
<span class="material-icons-outlined" aria-hidden="true">arrow_downward</span>
<span>降序</span>
</a>
</div>
<fieldset class="withdrawal-column-menu__fields">
<legend>显示字段</legend>
@foreach ($withdrawalColumns as $column)
<label>
<input type="checkbox" value="{{ $column['key'] }}" data-withdrawal-column-toggle checked>
<span>{{ $column['label'] }}</span>
</label>
@endforeach
</fieldset>
<div class="withdrawal-column-menu__actions">
<button type="button" data-withdrawal-columns-all>全选</button>
<button type="button" data-withdrawal-columns-reset>恢复默认</button>
</div>
<p class="withdrawal-column-menu__status" data-withdrawal-column-status aria-live="polite"></p>
</div>
</div>
<div class="biz-pagination">
@ -242,9 +334,36 @@
</section>
<style>
.withdrawal-management .biz-card-head { align-items:flex-start; gap:1rem; }
.withdrawal-management .biz-btn .material-icons-outlined { font-size: 1.1rem; }
.withdrawal-management .biz-btn-primary { border-color: #856632; background: #856632; color: #fff; }
.withdrawal-management .biz-btn-primary:hover { background: #6f5429; }
.withdrawal-management__heading { display:flex; min-width:0; flex-direction:column; gap:.55rem; }
.withdrawal-management__summary { display:flex; flex-wrap:wrap; gap:.35rem; }
.withdrawal-management__actions { display:flex; flex:0 0 auto; align-items:center; gap:.5rem; margin-left:auto; }
.withdrawal-management__actions .biz-btn { min-width:5.5rem; justify-content:center; }
.withdrawal-table-head { display:flex; min-width:max-content; align-items:center; justify-content:space-between; gap:.35rem; }
.withdrawal-management th.num .withdrawal-table-head { justify-content:flex-end; }
.withdrawal-table-head__menu { display:inline-flex; width:1.65rem; height:1.65rem; flex:0 0 1.65rem; align-items:center; justify-content:center; border:1px solid transparent; border-radius:4px; color:#856632; }
.withdrawal-table-head__menu:hover, .withdrawal-table-head__menu:focus-visible { border-color:#b9954f; background:#fff; outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.withdrawal-table-head__menu .material-icons-outlined { font-size:1rem; }
.withdrawal-column-menu { position:fixed; z-index:80; width:min(18rem,calc(100vw - 1.5rem)); max-height:min(32rem,calc(100vh - 1.5rem)); overflow:auto; padding:.75rem; border:1px solid #d8d2c7; border-radius:8px; background:#fff; color:#171717; box-shadow:0 4px 8px rgba(23,23,23,.1); }
.withdrawal-column-menu[hidden] { display:none; }
.withdrawal-column-menu__title { margin:0 0 .5rem; font-size:.8125rem; font-weight:600; }
.withdrawal-column-menu__sort { display:grid; grid-template-columns:1fr 1fr; gap:.35rem; padding-bottom:.65rem; border-bottom:1px solid #d8d2c7; }
.withdrawal-column-menu__sort a { display:flex; min-height:2rem; align-items:center; gap:.35rem; padding:0 .55rem; border:1px solid #d8d2c7; border-radius:6px; color:#171717; font-size:.75rem; text-decoration:none; }
.withdrawal-column-menu__sort a:hover, .withdrawal-column-menu__sort a:focus-visible, .withdrawal-column-menu__sort a.is-active { border-color:#856632; background:#f7f0de; color:#856632; outline:none; }
.withdrawal-column-menu__sort .material-icons-outlined { font-size:1rem; }
.withdrawal-column-menu__fields { display:grid; grid-template-columns:1fr 1fr; gap:.25rem .6rem; margin:.65rem 0 0; padding:0; border:0; }
.withdrawal-column-menu__fields legend { grid-column:1 / -1; width:100%; margin-bottom:.25rem; color:#595959; font-size:.75rem; font-weight:600; }
.withdrawal-column-menu__fields label { display:flex; min-width:0; min-height:1.75rem; align-items:center; gap:.4rem; font-size:.75rem; cursor:pointer; }
.withdrawal-column-menu__fields input { flex:0 0 auto; accent-color:#856632; }
.withdrawal-column-menu__fields span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.withdrawal-column-menu__actions { display:flex; gap:.35rem; margin-top:.6rem; padding-top:.6rem; border-top:1px solid #d8d2c7; }
.withdrawal-column-menu__actions button { min-height:1.8rem; padding:0 .55rem; border:1px solid #d8d2c7; border-radius:5px; background:#fff; color:#856632; font-size:.75rem; }
.withdrawal-column-menu__actions button:hover, .withdrawal-column-menu__actions button:focus-visible { border-color:#856632; background:#f7f0de; outline:none; }
.withdrawal-column-menu__status { min-height:1rem; margin:.45rem 0 0; color:#595959; font-size:.7rem; }
.withdrawal-management [data-withdrawal-column].is-column-hidden { display:none !important; }
.withdrawal-import-modal .biz-modal-card { max-width: 820px; border: 1px solid #d8d2c7; border-radius: 8px; }
.withdrawal-import__head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1rem; }
.withdrawal-import__head .biz-modal-title { margin:0; }
@ -262,6 +381,10 @@
.withdrawal-import__empty, .withdrawal-import__error { padding:.85rem 1rem; border:1px solid #d8d2c7; color:#595959; }
.withdrawal-import__error { margin-bottom:1rem; border-color:#d6a4a4; background:#fff7f7; color:#8a1f1f; }
@media (max-width: 640px) {
.withdrawal-management .biz-card-head { align-items:stretch; }
.withdrawal-management__actions { width:100%; margin-left:0; }
.withdrawal-management__actions .biz-btn { flex:1 1 0; }
.withdrawal-column-menu__fields { grid-template-columns:1fr; }
.withdrawal-import-modal { padding:1rem; }
.withdrawal-import__stats { grid-template-columns:1fr; }
.withdrawal-import__stats > div + div { border-left:0; border-top:1px solid #d8d2c7; }
@ -290,3 +413,119 @@
if (modal.dataset.autoOpen === 'true') open();
})();
</script>
<script>
(() => {
const table = document.querySelector('[data-withdrawal-table]');
const menu = document.querySelector('[data-withdrawal-column-menu]');
if (!table || !menu) return;
const triggers = [...table.querySelectorAll('[data-withdrawal-column-trigger]')];
const checks = [...menu.querySelectorAll('[data-withdrawal-column-toggle]')];
const ascending = menu.querySelector('[data-withdrawal-sort-asc]');
const descending = menu.querySelector('[data-withdrawal-sort-desc]');
const title = menu.querySelector('[data-withdrawal-menu-title]');
const status = menu.querySelector('[data-withdrawal-column-status]');
const storageKey = table.dataset.columnStorageKey;
let activeTrigger = null;
const checkedKeys = () => checks.filter((check) => check.checked).map((check) => check.value);
const close = () => {
menu.hidden = true;
activeTrigger?.setAttribute('aria-expanded', 'false');
activeTrigger = null;
};
const applyVisibility = (save = true, message = null) => {
const visible = new Set(checkedKeys());
table.querySelectorAll('[data-withdrawal-column]').forEach((cell) => {
cell.classList.toggle('is-column-hidden', !visible.has(cell.dataset.withdrawalColumn));
});
table.querySelectorAll('tbody td[colspan]').forEach((cell) => {
cell.colSpan = Math.max(visible.size, 1);
});
status.textContent = message || `已显示 ${visible.size}/${checks.length} 个字段`;
if (save && storageKey) {
try { localStorage.setItem(storageKey, JSON.stringify([...visible])); } catch (_) {}
}
};
const position = (trigger) => {
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const gap = 6;
const edge = 12;
let left = triggerRect.right - menuRect.width;
let top = triggerRect.bottom + gap;
left = Math.max(edge, Math.min(left, window.innerWidth - menuRect.width - edge));
if (top + menuRect.height > window.innerHeight - edge) {
top = Math.max(edge, triggerRect.top - menuRect.height - gap);
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
};
const open = (trigger) => {
if (activeTrigger === trigger && !menu.hidden) {
close();
return;
}
activeTrigger?.setAttribute('aria-expanded', 'false');
activeTrigger = trigger;
trigger.setAttribute('aria-expanded', 'true');
title.textContent = `${trigger.dataset.sortLabel} · 排序与字段`;
ascending.href = trigger.dataset.sortAsc;
descending.href = trigger.dataset.sortDesc;
const current = menu.dataset.currentSort === trigger.dataset.sortField;
ascending.classList.toggle('is-active', current && menu.dataset.currentDirection === 'asc');
descending.classList.toggle('is-active', current && menu.dataset.currentDirection === 'desc');
menu.hidden = false;
position(trigger);
ascending.focus({ preventScroll: true });
};
if (storageKey) {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (Array.isArray(saved) && saved.length > 0) {
const available = new Set(checks.map((check) => check.value));
checks.forEach((check) => { check.checked = saved.includes(check.value) || !available.has(check.value); });
}
} catch (_) {}
}
if (checkedKeys().length === 0) checks.forEach((check) => { check.checked = true; });
applyVisibility(false);
triggers.forEach((trigger) => trigger.addEventListener('click', (event) => {
event.stopPropagation();
open(trigger);
}));
checks.forEach((check) => check.addEventListener('change', () => {
if (checkedKeys().length === 0) {
check.checked = true;
applyVisibility(false, '至少保留一个显示字段');
return;
}
applyVisibility();
}));
menu.querySelector('[data-withdrawal-columns-all]')?.addEventListener('click', () => {
checks.forEach((check) => { check.checked = true; });
applyVisibility();
});
menu.querySelector('[data-withdrawal-columns-reset]')?.addEventListener('click', () => {
checks.forEach((check) => { check.checked = true; });
if (storageKey) {
try { localStorage.removeItem(storageKey); } catch (_) {}
}
applyVisibility(false, '已恢复默认字段');
});
menu.addEventListener('click', (event) => event.stopPropagation());
document.addEventListener('click', close);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !menu.hidden) {
const trigger = activeTrigger;
close();
trigger?.focus();
}
});
window.addEventListener('resize', close, { passive: true });
table.closest('.biz-table-wrap')?.addEventListener('scroll', close, { passive: true });
})();
</script>

View File

@ -142,19 +142,22 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('提现金额')
->assertSeeText('是否达标')
->assertSeeText('待核验')
->assertSee('data-finance-table-name="withdrawal-management-v3"', false)
->assertSee('data-finance-table-name="withdrawal-management-v4"', false)
->assertSee('data-finance-table-native', false)
->assertSee('data-withdrawal-column-menu', false)
->assertSee('data-withdrawal-column-trigger', false)
->assertSeeInOrder([
'<th>店铺平台</th>',
'<th>ERP店铺名称</th>',
'<th>平台店铺ID</th>',
'<th>店铺主体</th>',
'<th>提现路径</th>',
'<th>提现账户名称</th>',
'<th>银行卡号</th>',
'<th class="num" title="余额数据日期:2026-09-12">9月13日店铺余额</th>',
'<th class="num">预留标准</th>',
'<th class="num">提现金额</th>',
'<th>是否达标</th>',
'data-withdrawal-column="platform"',
'data-withdrawal-column="erp_store_name"',
'data-withdrawal-column="store_id"',
'data-withdrawal-column="store_entity"',
'data-withdrawal-column="withdrawal_path"',
'data-withdrawal-column="withdrawal_account"',
'data-withdrawal-column="bank_card"',
'data-withdrawal-column="balance"',
'data-withdrawal-column="reserve_standard"',
'data-withdrawal-column="withdrawal_amount"',
'data-withdrawal-column="compliance"',
], false)
->assertSee('aria-label="提现管理筛选"', false)
->assertSee('name="store_entity"', false)

View File

@ -56,10 +56,53 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
->assertSeeText('导入')
->assertSeeText('导出')
->assertSee('data-withdrawal-import-open', false)
->assertSee('class="withdrawal-management__actions"', false)
->assertSee('data-withdrawal-column-trigger', false)
->assertSee('data-withdrawal-column-toggle', false)
->assertSee('data-withdrawal-sort-asc', false)
->assertSee('data-withdrawal-sort-desc', false)
->assertSee(route('business.withdrawal-management.export', ['platform' => '天猫']), false)
->assertSee(route('business.withdrawal-management.template'), false);
}
public function test_page_sorts_all_filtered_rows_before_pagination(): void
{
foreach (['B排序测试店', 'A排序测试店'] as $name) {
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '排序测试平台',
'erp_store_name' => $name,
'store_entity_name' => '排序测试主体',
]);
}
$ascending = $this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '排序测试平台',
'sort' => 'erp_store_name',
'direction' => 'asc',
]));
$descending = $this->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '排序测试平台',
'sort' => 'erp_store_name',
'direction' => 'desc',
]));
$ascending
->assertOk()
->assertSeeInOrder(['A排序测试店', 'B排序测试店'])
->assertSee('name="sort" value="erp_store_name"', false)
->assertSee('name="direction" value="asc"', false)
->assertSee('aria-sort="ascending"', false);
$descending
->assertOk()
->assertSeeInOrder(['B排序测试店', 'A排序测试店'])
->assertSee('aria-sort="descending"', false);
}
public function test_export_contains_all_filtered_rows_and_excludes_passwords(): void
{
StoreReceivingAccount::query()->create([