feat(财务台账): 动账明细账户简称支持输入联想 #462

Merged
shanshi merged 1 commits from feature/shanshi-transaction-account-autocomplete into main 2026-10-22 18:45:55 +08:00
2 changed files with 186 additions and 1 deletions

View File

@ -172,6 +172,22 @@
.ledger-field label { color:var(--vip-content-muted, #595959); font-size:.7rem; font-weight:600; }
.ledger-field input, .ledger-field select { height:2.15rem; padding:0 .6rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; }
.ledger-field input:focus, .ledger-field select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); }
.ledger-field--account-filter { min-width:16rem; }
.ledger-filter-combobox { position:relative; display:flex; align-items:center; width:100%; }
.ledger-filter-combobox input { width:100%; padding-right:2.25rem; }
.ledger-filter-combobox input::-webkit-search-cancel-button { appearance:none; -webkit-appearance:none; }
.ledger-filter-combobox__toggle { position:absolute; right:.25rem; display:inline-grid; place-items:center; width:1.65rem; height:1.65rem; padding:0; border:0; border-radius:.3rem; background:transparent; color:var(--vip-content-muted, #595959); cursor:pointer; }
.ledger-filter-combobox__toggle:hover,
.ledger-filter-combobox__toggle:focus-visible { background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); outline:none; }
.ledger-filter-combobox__toggle .material-icons-outlined { font-size:1.05rem; transition:transform .15s ease; }
.ledger-filter-combobox.is-open .ledger-filter-combobox__toggle .material-icons-outlined { transform:rotate(180deg); }
.ledger-filter-combobox__menu { position:absolute; top:calc(100% + .35rem); right:0; left:0; z-index:35; max-height:min(18rem, 50vh); overflow:auto; padding:.25rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.5rem; background:#fff; box-shadow:0 5px 14px rgba(23,23,23,.13); overscroll-behavior:contain; scrollbar-color:var(--vip-content-accent, #B9954F) #efede8; scrollbar-width:thin; }
.ledger-filter-combobox__menu[hidden] { display:none !important; }
.ledger-filter-combobox__option { display:block; width:100%; min-height:1.8rem; padding:.32rem .5rem; overflow:hidden; border:0; border-radius:.35rem; background:transparent; color:var(--vip-content-ink, #171717); font:inherit; font-size:.72rem; text-align:left; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
.ledger-filter-combobox__option:hover,
.ledger-filter-combobox__option.is-active { background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); }
.ledger-filter-combobox__empty,
.ledger-filter-combobox__more { margin:.25rem 0 0; padding:.45rem .5rem; border-top:1px solid #e8e3da; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.4; text-align:center; }
.ledger-actions { display:flex; gap:.45rem; padding-bottom:.05rem; }
.ledger-edit-switch { position:relative; display:inline-flex; align-items:center; align-self:flex-end; gap:.45rem; height:2.15rem; padding:0 .65rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-muted, #595959); font-size:.72rem; font-weight:600; cursor:pointer; user-select:none; white-space:nowrap; }
.ledger-edit-switch input { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
@ -591,7 +607,16 @@
<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>
<div class="ledger-field"><label for="txns-account">账户简称</label><input id="txns-account" name="txns_account" list="ledger-account-list" value="{{ request('txns_account') }}" placeholder="输入或选择(含未匹配)"></div>
<div class="ledger-field ledger-field--account-filter">
<label for="txns-account">账户简称</label>
<div class="ledger-filter-combobox" data-ledger-account-filter data-limit="60">
<input id="txns-account" name="txns_account" type="search" value="{{ request('txns_account') }}" placeholder="输入文字自动联想" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="txns-account-menu" aria-autocomplete="list" aria-haspopup="listbox" data-ledger-account-filter-input>
<button class="ledger-filter-combobox__toggle" type="button" aria-label="展开账户简称选项" tabindex="-1" data-ledger-account-filter-toggle>
<span class="material-icons-outlined" aria-hidden="true">expand_more</span>
</button>
<div class="ledger-filter-combobox__menu" id="txns-account-menu" role="listbox" aria-label="账户简称选项" data-ledger-account-filter-menu hidden></div>
</div>
</div>
<div class="ledger-field"><label for="txns-source">来源</label><select id="txns-source" name="txns_source"><option value="">全部</option>@foreach ($sourceLabels as $key => $label)<option value="{{ $key }}" @selected(request('txns_source') === $key)>{{ $label }}</option>@endforeach</select></div>
<div class="ledger-field"><label for="txns-from">日期起</label><input id="txns-from" type="date" name="txns_from" value="{{ request('txns_from') }}"></div>
<div class="ledger-field"><label for="txns-to">日期止</label><input id="txns-to" type="date" name="txns_to" value="{{ request('txns_to') }}"></div>
@ -810,6 +835,144 @@
}));
if (window.location.hash === '#mapping') showTab('mapping', false);
const setupLedgerAccountFilter = () => {
const combobox = page.querySelector('[data-ledger-account-filter]');
const input = combobox?.querySelector('[data-ledger-account-filter-input]');
const menu = combobox?.querySelector('[data-ledger-account-filter-menu]');
const toggle = combobox?.querySelector('[data-ledger-account-filter-toggle]');
const optionsSource = page.querySelector('#ledger-account-list');
if (!combobox || !input || !menu || !optionsSource) return;
const limit = Math.max(1, Number(combobox.dataset.limit) || 60);
const seen = new Set();
const options = [];
const addOption = (value) => {
const text = String(value || '').trim();
if (!text || seen.has(text)) return;
seen.add(text);
options.push(text);
};
addOption('未匹配');
Array.from(optionsSource.options).forEach((option) => addOption(option.value));
let visibleValues = [];
let activeIndex = -1;
const isOpen = () => !menu.hidden;
const setActive = (index) => {
const buttons = Array.from(menu.querySelectorAll('[data-ledger-account-option]'));
activeIndex = buttons.length === 0 ? -1 : Math.min(Math.max(index, 0), buttons.length - 1);
buttons.forEach((button, buttonIndex) => {
const active = buttonIndex === activeIndex;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
const active = buttons[activeIndex];
if (active) {
input.setAttribute('aria-activedescendant', active.id);
active.scrollIntoView({ block: 'nearest' });
} else {
input.removeAttribute('aria-activedescendant');
}
};
const pick = (value) => {
input.value = value;
close();
input.focus();
};
const render = () => {
const needle = input.value.trim().toLocaleLowerCase();
const matches = options.filter((value) => !needle || value.toLocaleLowerCase().includes(needle));
visibleValues = matches.slice(0, limit);
menu.replaceChildren();
visibleValues.forEach((value, index) => {
const button = document.createElement('button');
button.id = 'txns-account-option-' + index;
button.type = 'button';
button.className = 'ledger-filter-combobox__option';
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', 'false');
button.dataset.ledgerAccountOption = '';
button.dataset.value = value;
button.textContent = value;
button.addEventListener('mousedown', (event) => event.preventDefault());
button.addEventListener('mouseenter', () => setActive(index));
button.addEventListener('click', () => pick(value));
menu.appendChild(button);
});
activeIndex = -1;
if (visibleValues.length === 0) {
const empty = document.createElement('p');
empty.className = 'ledger-filter-combobox__empty';
empty.textContent = '没有匹配的账户简称';
menu.appendChild(empty);
} else if (matches.length > limit) {
const more = document.createElement('p');
more.className = 'ledger-filter-combobox__more';
more.textContent = '仅显示前 ' + limit + ' 项,请继续输入缩小范围';
menu.appendChild(more);
}
input.removeAttribute('aria-activedescendant');
};
const open = (shouldRender = true) => {
if (shouldRender || !isOpen()) render();
menu.hidden = false;
combobox.classList.add('is-open');
input.setAttribute('aria-expanded', 'true');
};
const close = () => {
menu.hidden = true;
combobox.classList.remove('is-open');
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
activeIndex = -1;
menu.querySelectorAll('[data-ledger-account-option]').forEach((button) => {
button.classList.remove('is-active');
button.setAttribute('aria-selected', 'false');
});
};
const move = (step) => {
if (!isOpen()) {
open();
return;
}
if (!visibleValues.length) return;
let next = activeIndex === -1
? (step > 0 ? 0 : visibleValues.length - 1)
: activeIndex + step;
if (next < 0) next = visibleValues.length - 1;
if (next >= visibleValues.length) next = 0;
setActive(next);
};
input.addEventListener('focus', () => open());
input.addEventListener('click', () => open());
input.addEventListener('input', () => open(true));
input.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
move(event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (event.key === 'Escape' || event.key === 'Tab') {
close();
return;
}
if (event.key === 'Enter' && isOpen() && activeIndex >= 0) {
event.preventDefault();
pick(visibleValues[activeIndex]);
}
});
toggle?.addEventListener('click', () => {
if (isOpen()) close();
else open();
input.focus();
});
document.addEventListener('click', (event) => {
if (!combobox.contains(event.target)) close();
});
};
setupLedgerAccountFilter();
const headerDetails = Array.from(page.querySelectorAll('[data-ledger-header-details]'));
const closeHeaderMenus = (except = null) => {
headerDetails.forEach((details) => {

View File

@ -142,6 +142,28 @@ class FinanceLedgerHeaderControlsTest extends FeatureTestCase
}
}
public function test_transaction_account_filter_provides_typeahead_suggestions(): void
{
$html = $this->loginAs()->get($this->pageUrl())
->assertOk()
->assertSee('data-ledger-account-filter', false)
->assertSee('data-limit="60"', false)
->assertSee('id="txns-account"', false)
->assertSee('role="combobox"', false)
->assertSee('aria-autocomplete="list"', false)
->assertSee('aria-controls="txns-account-menu"', false)
->assertSee('aria-haspopup="listbox"', false)
->assertSee('data-ledger-account-filter-toggle', false)
->assertSee('data-ledger-account-filter-menu', false)
->assertSee('max-height:min(18rem, 50vh)', false)
->assertSee("input.addEventListener('input'", false)
->assertDontSee('id="txns-account" name="txns_account" list=', false)
->getContent();
$this->assertStringContainsString('<option value="账户甲"', $html);
$this->assertStringContainsString('<option value="账户乙"', $html);
}
public function test_account_and_flow_headers_filter_and_sort_the_full_dataset(): void
{
$filtered = $this->loginAs()->get($this->pageUrl([