feat: search tackle catalog by name
This commit is contained in:
@@ -44,13 +44,18 @@ def _rig_missing_fields(rig: Rig) -> list[str]:
|
||||
def tackle_items(
|
||||
db: Db,
|
||||
category: str | None = Query(None, pattern="^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$"),
|
||||
q: str | None = None,
|
||||
brand: str | None = None,
|
||||
family: str | None = None,
|
||||
unlock_level: int | None = Query(None, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PaginatedTackleItemOut:
|
||||
if q is not None and len(q) > 100:
|
||||
raise HTTPException(status_code=422, detail="search query is too long")
|
||||
query = select(TackleItem)
|
||||
if q and q.strip():
|
||||
query = query.where(TackleItem.name.ilike(f"%{q.strip()}%"))
|
||||
if category:
|
||||
query = query.where(TackleItem.category == category)
|
||||
if brand:
|
||||
|
||||
@@ -9,6 +9,7 @@ import { api, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/ap
|
||||
|
||||
const params = Astro.url.searchParams;
|
||||
const category = params.get("category") ?? "";
|
||||
const search = params.get("q") ?? "";
|
||||
const brand = params.get("brand") ?? "";
|
||||
const family = params.get("family") ?? "";
|
||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||
@@ -16,6 +17,7 @@ const limit = 48;
|
||||
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
const query = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (category) query.set("category", category);
|
||||
if (search) query.set("q", search);
|
||||
if (brand) query.set("brand", brand);
|
||||
if (family) query.set("family", family);
|
||||
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset }, unavailable = false, rigsUnavailable = false;
|
||||
@@ -29,6 +31,7 @@ if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retr
|
||||
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
||||
const filterParams = new URLSearchParams();
|
||||
if (category) filterParams.set("category", category);
|
||||
if (search) filterParams.set("q", search);
|
||||
if (brand) filterParams.set("brand", brand);
|
||||
if (family) filterParams.set("family", family);
|
||||
---
|
||||
@@ -36,10 +39,11 @@ if (family) filterParams.set("family", family);
|
||||
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
|
||||
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
|
||||
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
|
||||
<label>Поиск<input name="q" value={search} maxlength="100" placeholder="Название снасти или приманки" /></label>
|
||||
<label>Бренд<input name="brand" value={brand} maxlength="100" placeholder="Например, RF4" /></label>
|
||||
<label>Семейство<input name="family" value={family} maxlength="100" placeholder="Название семейства" /></label>
|
||||
<button data-action="primary">Фильтровать</button>
|
||||
{(category || brand || family) && <a data-action="quiet" href="/tackle">Сбросить</a>}
|
||||
{(category || search || brand || family) && <a data-action="quiet" href="/tackle">Сбросить</a>}
|
||||
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
|
||||
</form>
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small><div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk.
|
||||
- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
|
||||
- [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk.
|
||||
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированные `/api/v1/tackle/items` и `/api/v1/tackle/rigs` с фильтрами предметов по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь показывает обнаруживаемые карточки снастей и монтажей, имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом.
|
||||
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированные `/api/v1/tackle/items` и `/api/v1/tackle/rigs` с фильтрами предметов по названию, категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь показывает обнаруживаемые карточки снастей и монтажей, имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом.
|
||||
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода, роли и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или монтажа, а при конфликте идентификаторов сохраняет raw-значение без ложной ссылки. Остаётся дальнейшая визуальная приёмка.
|
||||
- [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; публичная медиатека фильтрует разрешённые роли через URL/API, admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
|
||||
- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff.
|
||||
|
||||
Reference in New Issue
Block a user