feat(发货数据): 供应商/店铺/仓库/快递公司筛选框自动补全

- 新增只读取值接口 shipping-data/suggest:按字段去重返回匹配候选(供应商/店铺名称/仓库/快递公司,快递取 express_name1/2 并集)
- ShippingOrderRepository::distinctValues 去重查询
- 前端 typeahead:输入弹下拉,支持鼠标点选与上下键/回车选择

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
This commit is contained in:
limeng 2026-09-21 03:36:07 +00:00
parent 3766fe4d4b
commit b9e0835ef5
4 changed files with 141 additions and 5 deletions

View File

@ -824,6 +824,25 @@ class Pages extends Controller
]);
}
public function suggestShippingData(Request $request): \Illuminate\Http\JsonResponse
{
$allowed = ['supplier_name', 'shop_name', 'warehouse_name', 'express_company'];
$field = (string) $request->query('field', '');
if (! in_array($field, $allowed, true)) {
return response()->json(['items' => []]);
}
$keyword = mb_substr(trim((string) $request->query('q', '')), 0, 64);
try {
$items = $this->shippingOrders->distinctValues($field, $keyword, 20);
} catch (Throwable $exception) {
report($exception);
$items = [];
}
return response()->json(['items' => array_values($items)]);
}
/** @return array<string,string> */
private function shippingDataFilters(Request $request): array
{

View File

@ -114,6 +114,47 @@ class ShippingOrderRepository
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
/**
* 取某字段去重候选值(供筛选框自动补全)。
* @return array<int,string>
*/
public function distinctValues(string $field, string $keyword, int $limit = 20): array
{
$map = [
'supplier_name' => ['supplier_name'],
'shop_name' => ['shop_name'],
'warehouse_name' => ['warehouse_name'],
'express_company' => ['express_name1', 'express_name2'],
];
if (! isset($map[$field])) {
return [];
}
$limit = min(max($limit, 1), 50);
$like = $this->contains($keyword);
$values = [];
foreach ($map[$field] as $col) {
$rows = $this->db->table('jst_oms_shipping')
->selectRaw($col . ' as v')
->whereRaw($col . " LIKE ? ESCAPE '!'", [$like])
->whereNotNull($col)
->where($col, '<>', '')
->groupBy($col)
->orderBy($col)
->limit($limit)
->pluck('v');
foreach ($rows as $v) {
$v = trim((string) $v);
if ($v !== '') {
$values[$v] = true;
}
}
}
$values = array_keys($values);
sort($values, SORT_FLAG_CASE | SORT_STRING);
return array_slice($values, 0, $limit);
}
/** 同步状态(供页面显示同步中/最近完成)。 */
public function syncStatus(): array
{

View File

@ -85,6 +85,11 @@
.shipping-data-page .sd-table tbody tr:hover { background: #fcfaf7; }
.shipping-data-page .sd-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: .72rem; color: #4b3a24; }
.shipping-data-page .sd-scroll { overflow-x: auto; max-width: 100%; }
.shipping-data-page .sd-ac-field { position: relative; }
.shipping-data-page .sd-ac-list { position: absolute; top: 100%; left: 0; right: 0; z-index: 40; margin-top: 2px; max-height: 15rem; overflow-y: auto; list-style: none; padding: 4px; background: #fff; border: 1px solid #d8d2c7; border-radius: .5rem; box-shadow: 0 8px 24px rgba(0,0,0,.10); }
.shipping-data-page .sd-ac-item { padding: .35rem .5rem; font-size: .8rem; border-radius: .375rem; cursor: pointer; color: #29251f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.shipping-data-page .sd-ac-item:hover, .shipping-data-page .sd-ac-item.is-active { background: #f3ece0; }
.shipping-data-page .sd-ac-empty { padding: .35rem .5rem; font-size: .75rem; color: #9a9182; }
</style>
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
@ -103,14 +108,14 @@
@endif
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="发货数据筛选">
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="发货数据筛选" data-suggest-url="{{ route('business.courier-payables.shipping-data.suggest') }}">
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-express-no"><span>快递单号</span><input id="shipping-express-no" class="sd-control" type="text" name="express_no" value="{{ $filters['express_no'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-trade-no"><span>交易单号</span><input id="shipping-trade-no" class="sd-control" type="text" name="sourceno" value="{{ $filters['sourceno'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-barcode"><span>条形码</span><input id="shipping-barcode" class="sd-control" type="text" name="barcode" value="{{ $filters['barcode'] ?? '' }}" placeholder="条形码关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-supplier"><span>供应商</span><input id="shipping-supplier" class="sd-control" type="text" name="supplier_name" value="{{ $filters['supplier_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-shop"><span>店铺名称</span><input id="shipping-shop" class="sd-control" type="text" name="shop_name" value="{{ $filters['shop_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-warehouse"><span>仓库</span><input id="shipping-warehouse" class="sd-control" type="text" name="warehouse_name" value="{{ $filters['warehouse_name'] ?? '' }}" placeholder="仓库关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-courier"><span>快递公司</span><input id="shipping-courier" class="sd-control" type="text" name="express_company" value="{{ $filters['express_company'] ?? '' }}" placeholder="匹配快递名称1/2"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-supplier"><span>供应商</span><input id="shipping-supplier" class="sd-control" type="text" name="supplier_name" data-suggest="supplier_name" autocomplete="off" value="{{ $filters['supplier_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-shop"><span>店铺名称</span><input id="shipping-shop" class="sd-control" type="text" name="shop_name" data-suggest="shop_name" autocomplete="off" value="{{ $filters['shop_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-warehouse"><span>仓库</span><input id="shipping-warehouse" class="sd-control" type="text" name="warehouse_name" data-suggest="warehouse_name" autocomplete="off" value="{{ $filters['warehouse_name'] ?? '' }}" placeholder="仓库关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-courier"><span>快递公司</span><input id="shipping-courier" class="sd-control" type="text" name="express_company" data-suggest="express_company" autocomplete="off" value="{{ $filters['express_company'] ?? '' }}" placeholder="匹配快递名称1/2"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-from"><span>发货时间起</span><span class="sd-date-picker"><input id="shipping-from" class="sd-control" type="date" name="ship_from" value="{{ $filters['ship_from'] ?? '' }}"><button type="button" data-date-picker-input="shipping-from" aria-label="选择发货开始日期" title="选择发货开始日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-to"><span>发货时间止</span><span class="sd-date-picker"><input id="shipping-to" class="sd-control" type="date" name="ship_to" value="{{ $filters['ship_to'] ?? '' }}"><button type="button" data-date-picker-input="shipping-to" aria-label="选择发货结束日期" title="选择发货结束日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<div class="flex gap-2">
@ -163,5 +168,72 @@
else input.click();
});
});
// 筛选框自动补全:输入关键词弹下拉,点选或键盘选择填入。
(() => {
const form = document.querySelector('.shipping-data-page form[aria-label="发货数据筛选"]');
const url = form ? form.dataset.suggestUrl : '';
if (!form || !url) return;
form.querySelectorAll('input[data-suggest]').forEach((input) => {
const label = input.closest('label');
if (!label) return;
label.classList.add('sd-ac-field');
const list = document.createElement('ul');
list.className = 'sd-ac-list';
list.hidden = true;
label.appendChild(list);
let timer = null;
let active = -1;
let seq = 0;
const hide = () => { list.hidden = true; list.innerHTML = ''; active = -1; };
const render = (items) => {
list.innerHTML = '';
active = -1;
if (!items.length) { hide(); return; }
items.forEach((it) => {
const li = document.createElement('li');
li.className = 'sd-ac-item';
li.textContent = it;
li.title = it;
li.addEventListener('mousedown', (e) => { e.preventDefault(); input.value = it; hide(); });
list.appendChild(li);
});
list.hidden = false;
};
const load = () => {
const my = ++seq;
const q = input.value.trim();
const u = url + '?field=' + encodeURIComponent(input.dataset.suggest) + '&q=' + encodeURIComponent(q);
fetch(u, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' })
.then((r) => r.ok ? r.json() : { items: [] })
.then((d) => { if (my === seq) render(Array.isArray(d.items) ? d.items : []); })
.catch(() => { if (my === seq) hide(); });
};
const schedule = (delay) => { clearTimeout(timer); timer = setTimeout(load, delay); };
input.addEventListener('input', () => schedule(200));
input.addEventListener('focus', () => schedule(120));
input.addEventListener('blur', () => setTimeout(hide, 150));
input.addEventListener('keydown', (e) => {
if (list.hidden) return;
const items = list.querySelectorAll('.sd-ac-item');
if (!items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, items.length - 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); }
else if (e.key === 'Enter') { if (active >= 0) { e.preventDefault(); input.value = items[active].textContent; hide(); } return; }
else if (e.key === 'Escape') { hide(); return; }
else return;
items.forEach((li, i) => li.classList.toggle('is-active', i === active));
items[active].scrollIntoView({ block: 'nearest' });
});
});
})();
})();
</script>

View File

@ -147,6 +147,10 @@ Route::get('workspace/courier-payables/shipping-data/export', 'Business\Pages@ex
->middleware('business.page:courier-payables,shipping-data')
->name('business.courier-payables.shipping-data.export');
Route::get('workspace/courier-payables/shipping-data/suggest', 'Business\\Pages@suggestShippingData')
->middleware('business.page:courier-payables,shipping-data')
->name('business.courier-payables.shipping-data.suggest');
Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage')
->middleware('business.page:courier-payables,third-party-warehouse-storage')
->name('business.courier-payables.third-party-warehouse-storage.export');