feat: search official records
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..dependencies import Db
|
from ..dependencies import Db
|
||||||
from ..models import (
|
from ..models import (
|
||||||
CatchReport,
|
CatchReport,
|
||||||
|
Bait,
|
||||||
CommunityImportRun,
|
CommunityImportRun,
|
||||||
DataSource,
|
DataSource,
|
||||||
ExternalObservation,
|
ExternalObservation,
|
||||||
@@ -117,9 +118,12 @@ def records(
|
|||||||
fish: str | None = None,
|
fish: str | None = None,
|
||||||
waterbody: str | None = None,
|
waterbody: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
|
q: str | None = None,
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
) -> PaginatedOfficialRecordOut:
|
) -> 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(
|
query = select(CatchReport).options(
|
||||||
joinedload(CatchReport.fish),
|
joinedload(CatchReport.fish),
|
||||||
joinedload(CatchReport.waterbody),
|
joinedload(CatchReport.waterbody),
|
||||||
@@ -131,6 +135,9 @@ def records(
|
|||||||
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||||
if category:
|
if category:
|
||||||
query = query.where(CatchReport.raw_payload["category"].as_string() == 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(
|
total = db.scalar(
|
||||||
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
||||||
) or 0
|
) or 0
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type
|
|||||||
const params = Astro.url.searchParams;
|
const params = Astro.url.searchParams;
|
||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const waterbody = params.get("waterbody") ?? "";
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
|
const search = params.get("q") ?? "";
|
||||||
const recordsLimit = 50;
|
const recordsLimit = 50;
|
||||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||||
let unavailable = false, showNoIndex = false, totalRecords = 0;
|
let unavailable = false, showNoIndex = false, totalRecords = 0;
|
||||||
try {
|
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}`);
|
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
|
||||||
items = paginated.items;
|
items = paginated.items;
|
||||||
totalRecords = paginated.total;
|
totalRecords = paginated.total;
|
||||||
@@ -36,7 +37,7 @@ const last = runs[0];
|
|||||||
---
|
---
|
||||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
|
<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>
|
<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>
|
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div>
|
||||||
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
|
{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>}
|
{!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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user