chore: call rigs assemblies in UI
This commit is contained in:
@@ -4,7 +4,7 @@ import { ago, kg, type Catch } from "../lib/api";
|
|||||||
import TackleGlyph from "./TackleGlyph.astro";
|
import TackleGlyph from "./TackleGlyph.astro";
|
||||||
const { catches } = Astro.props as { catches: Catch[] };
|
const { catches } = Astro.props as { catches: Catch[] };
|
||||||
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
||||||
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
||||||
---
|
---
|
||||||
<div class="catch-list">
|
<div class="catch-list">
|
||||||
{catches.map(item => {
|
{catches.map(item => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const mediaRoleLabels: Record<MediaRole, string> = {
|
|||||||
waterbody_screenshot: "Скриншот водоёма",
|
waterbody_screenshot: "Скриншот водоёма",
|
||||||
tackle_card: "Карточка снасти",
|
tackle_card: "Карточка снасти",
|
||||||
tackle_detail: "Деталь снасти",
|
tackle_detail: "Деталь снасти",
|
||||||
rig_diagram: "Схема монтажа",
|
rig_diagram: "Схема сборки",
|
||||||
tackle_screenshot: "Скриншот снасти",
|
tackle_screenshot: "Скриншот снасти",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
|
|||||||
const requested = Astro.url.searchParams.get("type") ?? "fish";
|
const requested = Astro.url.searchParams.get("type") ?? "fish";
|
||||||
const type = allowed.has(requested) ? requested : "fish";
|
const type = allowed.has(requested) ? requested : "fish";
|
||||||
const requestedRole = Astro.url.searchParams.get("role") ?? "";
|
const requestedRole = Astro.url.searchParams.get("role") ?? "";
|
||||||
const roleOptions: Record<string, Array<[string, string]>> = { waterbody: [["waterbody_cover", "Заставки"], ["waterbody_map", "Карты"], ["waterbody_depth_map", "Карты глубин"], ["waterbody_screenshot", "Скриншоты"]], tackle: [["tackle_card", "Карточки снастей"], ["tackle_detail", "Детали снастей"], ["rig_diagram", "Схемы монтажей"], ["tackle_screenshot", "Скриншоты снастей"]] };
|
const roleOptions: Record<string, Array<[string, string]>> = { waterbody: [["waterbody_cover", "Заставки"], ["waterbody_map", "Карты"], ["waterbody_depth_map", "Карты глубин"], ["waterbody_screenshot", "Скриншоты"]], tackle: [["tackle_card", "Карточки снастей"], ["tackle_detail", "Детали снастей"], ["rig_diagram", "Схемы сборок"], ["tackle_screenshot", "Скриншоты снастей"]] };
|
||||||
const selectedRole = (roleOptions[type] ?? []).some(([value]) => value === requestedRole) ? requestedRole : "";
|
const selectedRole = (roleOptions[type] ?? []).some(([value]) => value === requestedRole) ? requestedRole : "";
|
||||||
let assets: MediaAsset[] = [], unavailable = false;
|
let assets: MediaAsset[] = [], unavailable = false;
|
||||||
try { const query = new URLSearchParams({ entity_type: type }); if (selectedRole) query.set("media_role", selectedRole); assets = await api<MediaAsset[]>(`/api/v1/media/catalog?${query}`); } catch { unavailable = true; }
|
try { const query = new URLSearchParams({ entity_type: type }); if (selectedRole) query.set("media_role", selectedRole); assets = await api<MediaAsset[]>(`/api/v1/media/catalog?${query}`); } catch { unavailable = true; }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ try {
|
|||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
|
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
|
||||||
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
||||||
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
||||||
---
|
---
|
||||||
<Layout title="Сочетания снастей RF4 — RF4 Spotter" description="Подтверждённые сочетания снастей RF4 по одобренным наблюдениям, с выборкой и независимыми игроками." noindex={unavailable} errorPage={unavailable}>
|
<Layout title="Сочетания снастей RF4 — RF4 Spotter" description="Подтверждённые сочетания снастей RF4 по одобренным наблюдениям, с выборкой и независимыми игроками." noindex={unavailable} errorPage={unavailable}>
|
||||||
<PageHero eyebrow="Только одобренные наблюдения" title="Сочетания снастей" description="Факт использования отделён от рекомендации: малой выборке нельзя приписывать эффективность." variant="fish" />
|
<PageHero eyebrow="Только одобренные наблюдения" title="Сочетания снастей" description="Факт использования отделён от рекомендации: малой выборке нельзя приписывать эффективность." variant="fish" />
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ const [itemsResult, rigsResult] = await Promise.allSettled([
|
|||||||
if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true;
|
if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true;
|
||||||
if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true;
|
if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true;
|
||||||
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
|
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
|
||||||
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
||||||
const filterParams = new URLSearchParams();
|
const filterParams = new URLSearchParams();
|
||||||
if (category) filterParams.set("category", category);
|
if (category) filterParams.set("category", category);
|
||||||
if (search) filterParams.set("q", search);
|
if (search) filterParams.set("q", search);
|
||||||
if (brand) filterParams.set("brand", brand);
|
if (brand) filterParams.set("brand", brand);
|
||||||
if (family) filterParams.set("family", family);
|
if (family) filterParams.set("family", family);
|
||||||
---
|
---
|
||||||
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и монтажей Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailable}>
|
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailable}>
|
||||||
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
|
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
|
||||||
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
|
<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>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
|
||||||
@@ -47,6 +47,6 @@ if (family) filterParams.set("family", family);
|
|||||||
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
|
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
|
||||||
</form>
|
</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="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
|
{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="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
|
||||||
{!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Монтажи</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Монтажи временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Монтаж · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых монтажей пока нет" description="Монтажи появятся после разрешённой загрузки и проверки источников." />}</section>}
|
{!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Сборка · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых сборок пока нет" description="Сборки появятся после разрешённой загрузки и проверки источников." />}</section>}
|
||||||
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
|
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if (unavailable) {
|
|||||||
Astro.response.headers.set("Cache-Control", "no-store");
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
||||||
const missingLabels: Record<string, string> = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" };
|
const missingLabels: Record<string, string> = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" };
|
||||||
const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
|
const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
|
||||||
const sourceHref = safeHttpUrl(item?.source_url);
|
const sourceHref = safeHttpUrl(item?.source_url);
|
||||||
|
|||||||
@@ -23,17 +23,17 @@ if (unavailable) {
|
|||||||
Astro.response.headers.set("Cache-Control", "no-store");
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
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 missingLabels: Record<string, string> = { source_url: "ссылка на источник", source_checked_at: "дата проверки" };
|
||||||
const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
|
const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
|
||||||
const sourceHref = safeHttpUrl(rig?.source_url);
|
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}>
|
<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 ?? "Не найдено" }]} />
|
<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} />
|
<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="Открыть каталог снастей" /> : <>
|
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенный состав не показываем." /> : !rig ? <StatePanel tone="error" title="Такой сборки нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог снастей" /> : <>
|
||||||
<section class="tackle-detail content-grid" aria-label="Состав монтажа">
|
<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 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>
|
<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>
|
</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"} />
|
<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"} />
|
||||||
|
|||||||
+4
-4
@@ -63,16 +63,16 @@
|
|||||||
Этот пакет повторяет жизненный цикл W01–W08, но не смешивает разные уровни
|
Этот пакет повторяет жизненный цикл W01–W08, но не смешивает разные уровни
|
||||||
описания. Наживка/приманка — предмет, который указан в улове; снасть —
|
описания. Наживка/приманка — предмет, который указан в улове; снасть —
|
||||||
компонент комплекта (удилище, катушка, леска, крючок и т. п.); оснастка —
|
компонент комплекта (удилище, катушка, леска, крючок и т. п.); оснастка —
|
||||||
собранная схема или монтаж (например, донная, поплавочная, method). Число
|
собранная схема или сборка (например, донная, поплавочная, method). Число
|
||||||
найденных изображений приманок не считается размером полного каталога.
|
найденных изображений приманок не считается размером полного каталога.
|
||||||
|
|
||||||
- [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом.
|
- [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом.
|
||||||
- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы монтажа сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
|
- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы сборок сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
|
||||||
- [ ] **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.
|
- [ ] **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 и вручную подтвердить результаты.
|
- [ ] **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.
|
- [ ] **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-значение без ложной ссылки. Остаётся дальнейшая визуальная приёмка.
|
- [ ] **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-кандидатов без автоматической публикации.
|
- [ ] **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.
|
- [ ] **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