feat(财务台账): 优化动账列宽筛选与行高 #461
@ -58,6 +58,8 @@ class FinanceLedgerPageData
|
||||
'ledgerMeta' => $this->meta(),
|
||||
'ledgerAccountOptions' => $this->accountOptions(),
|
||||
'ledgerManualTransactionAccountOptions' => $this->manualTransactionAccountOptions(),
|
||||
'ledgerTransactionAccountHeaderOptions' => $this->transactionAccountHeaderOptions(),
|
||||
'ledgerTransactionDateHeaderOptions' => $this->transactionDateHeaderOptions(),
|
||||
'ledgerAccountFilterOptions' => $this->accountFilterOptions(),
|
||||
'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS,
|
||||
'ledgerAccountPlanOptions' => $this->accountPlanOptions(),
|
||||
@ -367,6 +369,40 @@ class FinanceLedgerPageData
|
||||
->selectRaw($effectiveAccount . ' AS effective_account_short');
|
||||
}
|
||||
|
||||
private function transactionAccountHeaderOptions(): array
|
||||
{
|
||||
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
|
||||
|
||||
return $this->db()->table('fin_transactions as t')
|
||||
->leftJoin('fin_account_map as m', function ($join): void {
|
||||
$join->on('m.source', '=', 't.source')
|
||||
->on('m.source_value', '=', 't.source_value');
|
||||
})
|
||||
->selectRaw($effectiveAccount . ' AS filter_value')
|
||||
->distinct()
|
||||
->orderBy('filter_value')
|
||||
->pluck('filter_value')
|
||||
->map(static fn (mixed $value): string => trim((string) $value))
|
||||
->filter()
|
||||
->unique()
|
||||
->mapWithKeys(static fn (string $value): array => [$value => $value])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function transactionDateHeaderOptions(): array
|
||||
{
|
||||
return $this->db()->table('fin_transactions')
|
||||
->select('txn_date')
|
||||
->distinct()
|
||||
->orderByDesc('txn_date')
|
||||
->pluck('txn_date')
|
||||
->map(static fn (mixed $value): string => (string) $value)
|
||||
->filter()
|
||||
->unique()
|
||||
->mapWithKeys(static fn (string $value): array => [$value => $value])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function mapping(): array
|
||||
{
|
||||
$query = $this->db()->table('fin_account_map');
|
||||
|
||||
@ -56,6 +56,8 @@
|
||||
$tabUrl = static fn (string $tab): string => $ledgerPageUrl . '?' . http_build_query(['tab' => $tab]);
|
||||
$sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入'];
|
||||
$manualTransactionAccountOptions = $ledgerManualTransactionAccountOptions ?? [];
|
||||
$transactionAccountHeaderOptions = $ledgerTransactionAccountHeaderOptions ?? [];
|
||||
$transactionDateHeaderOptions = $ledgerTransactionDateHeaderOptions ?? [];
|
||||
$optionMap = static fn (array $values): array => array_combine($values, $values) ?: [];
|
||||
$ledgerHeaderPreserve = [];
|
||||
foreach (array_keys($tabs) as $tabKey) {
|
||||
@ -120,8 +122,8 @@
|
||||
['remark', '异常/备注', 'text'],
|
||||
],
|
||||
'transactions' => [
|
||||
['txn_date', '日期', 'date'],
|
||||
['effective_account_short', '账户简称', 'text'],
|
||||
['txn_date', '日期', 'date', ['options' => $transactionDateHeaderOptions]],
|
||||
['effective_account_short', '账户简称', 'enum', ['options' => $transactionAccountHeaderOptions]],
|
||||
['detail', '明细', 'text'],
|
||||
['source', '来源', 'enum', ['options' => $sourceLabels]],
|
||||
['income', '收入', 'number', ['headerClass' => 'num', 'align' => 'end']],
|
||||
@ -262,6 +264,8 @@
|
||||
.ledger-table th:first-child { z-index:3; background:var(--vip-content-accent-soft, #F7F0DE); }
|
||||
.ledger-table tr:hover td:first-child { background:#fcfaf4; }
|
||||
.ledger-table td.num, .ledger-table th.num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
#ledger-panel-transactions .ledger-transaction-row td { height:2.45rem; vertical-align:middle; }
|
||||
#ledger-panel-transactions .ledger-transaction-row td.wrap { min-width:0; max-width:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ledger-manual-trigger-row td { position:static !important; padding:.18rem .45rem; background:#fbf8f0; }
|
||||
.ledger-manual-trigger { display:inline-flex; align-items:center; gap:.35rem; height:1.75rem; padding:0 .55rem; border:0; border-radius:.35rem; background:transparent; color:var(--vip-content-accent-dark, #856632); font:inherit; font-size:.7rem; font-weight:700; cursor:pointer; }
|
||||
.ledger-manual-trigger:hover { background:var(--vip-content-accent-soft, #F7F0DE); }
|
||||
@ -583,7 +587,7 @@
|
||||
</section>
|
||||
|
||||
{{-- 动账明细 --}}
|
||||
<section class="ledger-panel" id="ledger-panel-transactions" role="tabpanel" data-ledger-panel="transactions" data-transaction-account-url-template="{{ route('business.financial-ledger.transactions.account.update', ['id' => '__TRANSACTION__']) }}" @if ($activeTab !== 'transactions') hidden @endif>
|
||||
<section class="ledger-panel" id="ledger-panel-transactions" role="tabpanel" data-ledger-panel="transactions" data-transaction-account-url-template="{{ route('business.financial-ledger.transactions.account.update', ['id' => '__TRANSACTION__']) }}" data-transaction-column-storage-key="caiwu.financial-ledger.transactions.widths.v1.{{ auth()->id() }}.{{ company_id() }}" @if ($activeTab !== 'transactions') hidden @endif>
|
||||
<form class="ledger-toolbar" method="GET" action="{{ $tabUrl('transactions') }}">
|
||||
<input type="hidden" name="tab" value="transactions">
|
||||
<div class="ledger-field" style="min-width:15rem"><label for="txns-q">关键词</label><input id="txns-q" name="txns_q" value="{{ request('txns_q') }}" placeholder="账户、明细、审批编号"></div>
|
||||
@ -594,7 +598,7 @@
|
||||
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('transactions') }}"><span class="material-icons-outlined">refresh</span>重置</a></div>
|
||||
</form>
|
||||
<div class="ledger-summary"><div class="ledger-summary__item"><span>明细笔数</span><b data-ledger-transaction-summary-count>{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>收入合计</span><b data-ledger-transaction-summary-income>{{ $money($ledgerTransactionSummary['income'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>支出合计</span><b data-ledger-transaction-summary-expense>{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>净额</span><b data-ledger-transaction-summary-net>{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}</b></div></div>
|
||||
<div class="ledger-note">账户简称按“映射表 → 来源原始值 → 未匹配”解析;手工录入使用账户总表简称,并自动计入每日资金流水,不回写上游。</div>
|
||||
<div class="ledger-note">账户简称按“映射表 → 来源原始值 → 未匹配”解析;手工录入使用账户总表简称,并自动计入每日资金流水,不回写上游。拖动表头右侧边界可调整列宽,双击边界恢复默认。</div>
|
||||
<div class="ledger-table-wrap">
|
||||
<table class="ledger-table" style="min-width:66rem"><thead><tr>
|
||||
@foreach ($ledgerTableHeaders['transactions'] as $header)
|
||||
@ -1503,6 +1507,140 @@
|
||||
applyAccountColumnLayout();
|
||||
}
|
||||
|
||||
const transactionPanel = page.querySelector('#ledger-panel-transactions');
|
||||
const transactionTable = transactionPanel?.querySelector('.ledger-table');
|
||||
if (transactionPanel && transactionTable) {
|
||||
const transactionHeaders = Array.from(transactionTable.querySelectorAll('thead th'));
|
||||
const transactionDefaults = Object.freeze({
|
||||
txn_date: 112,
|
||||
effective_account_short: 220,
|
||||
detail: 340,
|
||||
source: 110,
|
||||
income: 110,
|
||||
expense: 110,
|
||||
approval_no: 220,
|
||||
});
|
||||
const transactionStorageKey = transactionPanel.dataset.transactionColumnStorageKey
|
||||
|| 'caiwu.financial-ledger.transactions.widths';
|
||||
const transactionWidths = new Map();
|
||||
const transactionKeys = transactionHeaders.map((header, index) => {
|
||||
const field = header.dataset.ledgerHeaderField || ('transaction-column-' + index);
|
||||
transactionWidths.set(
|
||||
field,
|
||||
Number(transactionDefaults[field]) || Math.max(80, Math.round(header.getBoundingClientRect().width) || 140),
|
||||
);
|
||||
header.dataset.transactionColumnKey = field;
|
||||
return field;
|
||||
});
|
||||
const clampTransactionWidth = (value) => Math.min(720, Math.max(48, Math.round(Number(value) || 0)));
|
||||
const transactionColumnGroup = () => {
|
||||
let group = transactionTable.querySelector('colgroup[data-transaction-column-group]');
|
||||
if (!group) {
|
||||
group = document.createElement('colgroup');
|
||||
group.dataset.transactionColumnGroup = 'true';
|
||||
transactionTable.insertBefore(group, transactionTable.firstChild);
|
||||
}
|
||||
transactionKeys.forEach((key) => {
|
||||
if (group.querySelector('col[data-transaction-column-key="' + key + '"]')) return;
|
||||
const column = document.createElement('col');
|
||||
column.dataset.transactionColumnKey = key;
|
||||
group.appendChild(column);
|
||||
});
|
||||
|
||||
return group;
|
||||
};
|
||||
const applyTransactionColumnWidths = () => {
|
||||
const group = transactionColumnGroup();
|
||||
let totalWidth = 0;
|
||||
transactionKeys.forEach((key) => {
|
||||
const width = clampTransactionWidth(transactionWidths.get(key));
|
||||
const column = group.querySelector('col[data-transaction-column-key="' + key + '"]');
|
||||
if (column) column.style.width = width + 'px';
|
||||
totalWidth += width;
|
||||
transactionTable.querySelector(
|
||||
'thead th[data-transaction-column-key="' + key + '"] [data-transaction-column-resizer]',
|
||||
)?.setAttribute('aria-valuenow', String(width));
|
||||
});
|
||||
transactionTable.style.tableLayout = 'fixed';
|
||||
transactionTable.style.width = Math.max(1, totalWidth) + 'px';
|
||||
transactionTable.style.minWidth = Math.max(1, totalWidth) + 'px';
|
||||
};
|
||||
const saveTransactionColumnWidths = () => {
|
||||
try {
|
||||
localStorage.setItem(transactionStorageKey, JSON.stringify(Object.fromEntries(transactionWidths)));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
try {
|
||||
const savedWidths = JSON.parse(localStorage.getItem(transactionStorageKey) || 'null');
|
||||
transactionKeys.forEach((key) => {
|
||||
if (savedWidths && savedWidths[key] !== undefined) {
|
||||
transactionWidths.set(key, clampTransactionWidth(savedWidths[key]));
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
transactionHeaders.forEach((header) => {
|
||||
const key = header.dataset.transactionColumnKey;
|
||||
if (!key || header.querySelector('[data-transaction-column-resizer]')) return;
|
||||
const label = accountHeaderLabel(header);
|
||||
const resizer = document.createElement('span');
|
||||
resizer.className = 'ledger-column-resizer';
|
||||
resizer.dataset.transactionColumnResizer = 'true';
|
||||
resizer.tabIndex = 0;
|
||||
resizer.setAttribute('role', 'separator');
|
||||
resizer.setAttribute('aria-orientation', 'vertical');
|
||||
resizer.setAttribute('aria-label', '调整“' + label + '”列宽');
|
||||
resizer.setAttribute('aria-valuemin', '48');
|
||||
resizer.setAttribute('aria-valuemax', '720');
|
||||
resizer.title = '拖动调整列宽,双击恢复默认';
|
||||
|
||||
const setWidth = (value, persist = false) => {
|
||||
transactionWidths.set(key, clampTransactionWidth(value));
|
||||
applyTransactionColumnWidths();
|
||||
if (persist) saveTransactionColumnWidths();
|
||||
};
|
||||
|
||||
resizer.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
const startWidth = Number(transactionWidths.get(key)) || header.getBoundingClientRect().width;
|
||||
resizer.classList.add('is-active');
|
||||
document.body.classList.add('is-resizing-ledger-column');
|
||||
const move = (moveEvent) => setWidth(startWidth + moveEvent.clientX - startX);
|
||||
const finish = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', finish);
|
||||
document.removeEventListener('pointercancel', finish);
|
||||
resizer.classList.remove('is-active');
|
||||
document.body.classList.remove('is-resizing-ledger-column');
|
||||
saveTransactionColumnWidths();
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', finish);
|
||||
document.addEventListener('pointercancel', finish);
|
||||
});
|
||||
resizer.addEventListener('keydown', (event) => {
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const currentWidth = Number(transactionWidths.get(key)) || header.getBoundingClientRect().width;
|
||||
setWidth(currentWidth + (event.key === 'ArrowLeft' ? -1 : 1) * (event.shiftKey ? 24 : 8), true);
|
||||
});
|
||||
resizer.addEventListener('dblclick', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setWidth(transactionDefaults[key] || 140, true);
|
||||
notify(label + '列宽已恢复默认');
|
||||
});
|
||||
resizer.addEventListener('click', (event) => event.stopPropagation());
|
||||
header.appendChild(resizer);
|
||||
});
|
||||
|
||||
applyTransactionColumnWidths();
|
||||
}
|
||||
|
||||
const postMappingBulk = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
@ -132,6 +132,10 @@ class FinanceLedgerHeaderControlsTest extends FeatureTestCase
|
||||
|
||||
$this->assertSame(63, preg_match_all('/<details\b[^>]*data-ledger-header-details/', $html));
|
||||
$this->assertSame(63, preg_match_all('/<span\b[^>]*data-ledger-header-label/', $html));
|
||||
$this->assertSame(3, preg_match_all('/<select name="transactions_filter_value"/', $html));
|
||||
$this->assertStringContainsString('<option value="账户甲"', $html);
|
||||
$this->assertStringContainsString('<option value="2026-10-18"', $html);
|
||||
$this->assertStringContainsString('data-transaction-column-storage-key', $html);
|
||||
foreach (['accounts', 'shops', 'flows', 'transactions', 'mapping', 'sources'] as $tab) {
|
||||
$this->assertStringContainsString('name="' . $tab . '_filter_value"', $html);
|
||||
$this->assertStringContainsString($tab . '_sort=', $html);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user