feat: add public rig detail pages
This commit is contained in:
@@ -10,7 +10,7 @@ const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Н
|
||||
{catches.map(item => {
|
||||
const timestamp = item.caught_at ?? item.reported_at;
|
||||
return <article>
|
||||
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span>{item.fishing_method && <span>Метод: {methodLabels[item.fishing_method] ?? item.fishing_method}</span>}{item.retrieve_method && <span>Проводка: {item.retrieve_method}{item.retrieve_speed != null ? ` · скорость ${item.retrieve_speed}` : ""}</span>}{item.tackle_components?.length > 0 && <details class="catch-components"><summary>Комплект снастей ({item.tackle_components.length})</summary><ul>{item.tackle_components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value}</a> : <span>{component.raw_value}</span>}</li>)}</ul></details>}<SourceBadge source={item.source_system} href={item.source_url}/></div>
|
||||
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span>{item.fishing_method && <span>Метод: {methodLabels[item.fishing_method] ?? item.fishing_method}</span>}{item.retrieve_method && <span>Проводка: {item.retrieve_method}{item.retrieve_speed != null ? ` · скорость ${item.retrieve_speed}` : ""}</span>}{item.tackle_components?.length > 0 && <details class="catch-components"><summary>Комплект снастей ({item.tackle_components.length})</summary><ul>{item.tackle_components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.rig_id ? <a href={`/tackle/rigs/${component.rig_id}`}>{component.raw_value}</a> : component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value}</a> : <span>{component.raw_value}</span>}</li>)}</ul></details>}<SourceBadge source={item.source_system} href={item.source_url}/></div>
|
||||
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
|
||||
</article>;
|
||||
})}
|
||||
|
||||
@@ -16,6 +16,8 @@ export type PaginatedActivity = {
|
||||
|
||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[] };
|
||||
export type CatchTackleComponent = { id: string; role: string; position: number; raw_value: string; tackle_item_id: string | null; rig_id: string | null; source_system: string | null; source_url: string | null };
|
||||
export type RigComponent = { id: string; role: string; position: number; raw_value: string | null; tackle_item_id: string | null };
|
||||
export type Rig = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; components: RigComponent[] };
|
||||
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; fishing_method: string | null; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null; tackle_components: CatchTackleComponent[] };
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
||||
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
|
||||
import DataPassport from "../../../components/DataPassport.astro";
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import PageHero from "../../../components/PageHero.astro";
|
||||
import StatePanel from "../../../components/StatePanel.astro";
|
||||
import TackleGlyph from "../../../components/TackleGlyph.astro";
|
||||
import { ApiError, api, type Rig } from "../../../lib/api";
|
||||
import { safeHttpUrl } from "../../../lib/urls";
|
||||
|
||||
const { id } = Astro.params;
|
||||
let rig: Rig | undefined;
|
||||
let unavailable = false;
|
||||
try {
|
||||
rig = await api<Rig>(`/api/v1/tackle/rigs/${encodeURIComponent(id ?? "")}`);
|
||||
} catch (error) {
|
||||
unavailable = !(error instanceof ApiError) || error.status >= 500;
|
||||
}
|
||||
if (!rig && !unavailable) Astro.response.status = 404;
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
Astro.response.headers.set("Retry-After", "60");
|
||||
Astro.response.headers.set("Cache-Control", "no-store");
|
||||
}
|
||||
|
||||
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
||||
const missingLabels: Record<string, string> = { source_url: "ссылка на источник", source_checked_at: "дата проверки" };
|
||||
const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
|
||||
const sourceHref = safeHttpUrl(rig?.source_url);
|
||||
---
|
||||
<Layout title={rig ? `${rig.name} — монтажи RF4` : "Монтаж не найден — RF4 Spotter"} description={rig ? `Подтверждённая карточка монтажа ${rig.name} в каталоге RF4 с составом и источником.` : "Такой карточки монтажа нет в каталоге RF4."} noindex={!rig || unavailable} errorPage={!rig || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: "Монтажи" }, { label: rig?.name ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Канонический монтаж" title={rig?.name ?? (unavailable ? "Каталог недоступен" : "Монтаж не найден")} description={rig ? "Состав монтажа показан в исходном порядке; неподтверждённые значения не дополняются догадками." : undefined} variant={rig ? "fish" : undefined} identity={rig?.name} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенный состав не показываем." /> : !rig ? <StatePanel tone="error" title="Такого монтажа нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог снастей" /> : <>
|
||||
<section class="tackle-detail content-grid" aria-label="Состав монтажа">
|
||||
<div class="tackle-detail__identity"><TackleGlyph name={rig.name} size={72} /><span class="overline">Монтаж</span><h2>{rig.name}</h2><p>{rig.source_external_id ? `Идентификатор источника: ${rig.source_external_id}` : "Внешний идентификатор не указан."}</p></div>
|
||||
<div><span class="overline">Компоненты</span><ol class="rig-components">{rig.components.length ? rig.components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value ?? "Карточка снасти"}</a> : <span>{component.raw_value ?? "Значение не указано"}</span>}</li>) : <li>Состав не указан.</li>}</ol>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div>
|
||||
</section>
|
||||
<DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"} />
|
||||
</>}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.rig-components { display: grid; gap: 10px; margin: 14px 0 20px; padding-left: 22px; }
|
||||
.rig-components li { padding-left: 4px; color: var(--text-secondary); }
|
||||
.rig-components li > span:first-child { display: inline-block; min-width: 110px; margin-right: 8px; color: var(--text-subtle); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.rig-components a { color: inherit; text-underline-offset: 3px; }
|
||||
</style>
|
||||
+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` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-route `/tackle/items/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты, ведёт в canonical detail только при наличии `tackle_item_id`, а unresolved raw value оставляет текстом.
|
||||
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, 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-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. Остаётся дальнейшая визуальная приёмка.
|
||||
- [ ] **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 для будущих ролей; 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