feat(财务台账): 优化动账列宽筛选与行高 #461

Merged
shanshi merged 1 commits from feature/shanshi-transaction-table-layout into main 2026-10-22 18:03:56 +08:00
3 changed files with 182 additions and 4 deletions

View File

@ -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');

View File

@ -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',

View File

@ -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);