feat: search official records
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run

This commit is contained in:
ik
2026-09-21 20:21:12 +07:00
parent f711b9ac0b
commit 55e5810e65
2 changed files with 12 additions and 4 deletions
+9 -2
View File
@@ -1,13 +1,14 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from sqlalchemy import func, select
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import joinedload
from ..config import settings
from ..dependencies import Db
from ..models import (
CatchReport,
Bait,
CommunityImportRun,
DataSource,
ExternalObservation,
@@ -117,9 +118,12 @@ def records(
fish: str | None = None,
waterbody: str | None = None,
category: str | None = None,
q: str | None = None,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedOfficialRecordOut:
if q is not None and len(q) > 100:
raise HTTPException(status_code=422, detail="record search query is too long")
query = select(CatchReport).options(
joinedload(CatchReport.fish),
joinedload(CatchReport.waterbody),
@@ -131,6 +135,9 @@ def records(
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
if category:
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
if q and q.strip():
needle = f"%{q.strip()}%"
query = query.outerjoin(Bait, CatchReport.bait_id == Bait.id).where(or_(CatchReport.player_name.ilike(needle), Bait.name.ilike(needle)))
total = db.scalar(
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
) or 0
+3 -2
View File
@@ -9,13 +9,14 @@ import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type
const params = Astro.url.searchParams;
const fish = params.get("fish") ?? "";
const waterbody = params.get("waterbody") ?? "";
const search = params.get("q") ?? "";
const recordsLimit = 50;
const requestedOffset = Number(params.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let unavailable = false, showNoIndex = false, totalRecords = 0;
try {
const query = new URLSearchParams({ fish, waterbody, limit: String(recordsLimit), offset: String(offset) });
const query = new URLSearchParams({ fish, waterbody, q: search, limit: String(recordsLimit), offset: String(offset) });
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
items = paginated.items;
totalRecords = paginated.total;
@@ -36,7 +37,7 @@ const last = runs[0];
---
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><label>Игрок или приманка<input name="q" value={search} maxlength="100" placeholder="Например, игрок или блесна" /></label><button data-action="primary">Фильтровать</button>{(fish || waterbody || search) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div>
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}