// web/screens/shoots.jsx — экран «Мои съёмки», по дизайну _design/.../shoots.jsx. // Стадии конвейера, в порядке отображения. Соответствуют значениям // shoot.Stage из pkg/pipeline/orchestrator.go. const STAGES = [ { id: "recognizing", short: "распз", label: "Распознавание" }, { id: "plan", short: "план", label: "План вариативности" }, { id: "analyze", short: "анализ", label: "Анализ" }, { id: "review1", short: "р1", label: "Ревью 1" }, { id: "generate", short: "тексты", label: "Тексты" }, { id: "review2", short: "р2", label: "Ревью 2" }, { id: "csv", short: "csv", label: "csv" }, { id: "done", short: "exif", label: "EXIF · watcher" }, ]; // stageInfo(stage, shoot?) — возвращает { idx, state, statusText } для shoot. // idx — индекс текущей стадии в STAGES // state — pending | running | waiting | done | failed // statusText — короткая русская подпись для пилюли // // shoot опционален: если передан, мы учитываем фактический прогресс // (Recognized/Photos), чтобы не показывать spinner когда все фотки готовы, // но автоматический переход stage не случился (правка 2026-06-05). function stageInfo(stage, shoot) { if (stage === "new") return { idx: 0, state: "pending", statusText: "В очереди на распознавание" }; if (stage === "failed") return { idx: 0, state: "failed", statusText: "Сбой обработки (детали в логах)" }; if (stage === "done") return { idx: 7, state: "done", statusText: "Готово" }; if (stage === "review1") return { idx: 3, state: "waiting", statusText: "Ждёт ревью 1" }; if (stage === "review2") return { idx: 5, state: "waiting", statusText: "Ждёт ревью 2" }; // Если recognize фактически завершён (все фотки в recognized) — показываем // как «готов к анализу», даём кнопку «Сделать анализ →». if (stage === "recognizing" && shoot && shoot.Photos > 0 && shoot.Recognized >= shoot.Photos) { return { idx: 0, state: "waiting", statusText: "Готов к анализу" }; } const idx = STAGES.findIndex((s) => s.id === stage); if (idx >= 0) return { idx, state: "running", statusText: STAGES[idx].label }; return { idx: 0, state: "pending", statusText: stage || "—" }; } const MEDIUM_META = { photo: { label: "Фото", icon: "image" }, video: { label: "Видео", icon: "film" }, vector: { label: "Vector", icon: "layers" }, }; function KindPills({ medium, editorial }) { const m = MEDIUM_META[medium]; return ( <> {medium !== "photo" && m && ( {m.label} )} {editorial && ( Editorial )} ); } function StagePipeline({ info }) { return (
{STAGES.map((s, i) => { let state = "pending"; if (info.state === "done") state = "done"; else if (i < info.idx) state = "done"; else if (i === info.idx) state = info.state; return (
{s.label}
); })}
); } function ShootStatusPill({ info }) { let cls = "pill"; if (info.state === "done") cls = "pill ok"; else if (info.state === "waiting") cls = "pill accent"; else if (info.state === "running") cls = "pill accent"; else if (info.state === "failed") cls = "pill warn"; else if (info.state === "pending" && info.idx === 0) cls = "pill warn"; return ( {info.statusText} ); } // shootDateISO — дата съёмки серии из префикса Title «ГГММДД …» (задаётся при // импорте по EXIF, см. pkg/ingest). Возвращает "YYYY-MM-DD" или null, если // префикса нет. По этой дате в БД датируются релизы (ключ release.releaseDate). function shootDateISO(title) { const m = /^(\d{2})(\d{2})(\d{2})(?:\D|$)/.exec(title || ""); if (!m) return null; return "20" + m[1] + "-" + m[2] + "-" + m[3]; } // ShootReleases — блок «Релизы» в карточке серии (T16, arch.md §16.1): готовые // релизы моделей на дату этой съёмки (матчинг shootDateISO == release.releaseDate) // как чипы + вход в создание релиза прямо из серии (дата фиксирована = дата серии). function ShootReleases({ dateISO, setRoute }) { const [, toast] = useToasts(); const [rels, setRels] = React.useState(null); const [creating, setCreating] = React.useState(false); const load = React.useCallback(() => { api.release.ListByDate(dateISO) .then((r) => setRels(Array.isArray(r) ? r : [])) .catch(() => setRels([])); }, [dateISO]); React.useEffect(() => { load(); }, [load]); return (
setRoute("releases")} title="Открыть экран релизов">Релизы: {(rels || []).map((r) => ( {r.Model} ))} {rels && rels.length === 0 && пока нет}
{creating && ( )}
); } // ShootReleaseForm — инлайн-форма создания релизов из серии: выбор НЕСКОЛЬКИХ // моделей чекбоксами + одно общее описание на всех (arch.md §16.1: «бывает общее // на всю съёмку») + генерация на дату серии одним запросом. Дата фиксирована. function ShootReleaseForm({ dateISO, onReload, toast }) { const [models, setModels] = React.useState(null); const [sel, setSel] = React.useState({}); // { [modelID]: true } const [desc, setDesc] = React.useState(""); const [busy, setBusy] = React.useState(false); const [translating, setTranslating] = React.useState(false); const [results, setResults] = React.useState(null); // [{ModelID, OutputName, Error}] React.useEffect(() => { api.release.ListModels().then((m) => { // только модели, которыми можно сгенерировать (есть бланк и фото) const flat = []; ["Adult", "Child", "Property"].forEach((g) => (m[g] || []).forEach((x) => { if (x.HasBlank && (x.Years || []).length) flat.push(x); })); setModels(flat); }).catch(() => setModels([])); }, []); const toggle = (id) => setSel((s) => ({ ...s, [id]: !s[id] })); const selectedIDs = models ? models.filter((m) => sel[m.ID]).map((m) => m.ID) : []; const translate = () => { if (!desc.trim()) return; setTranslating(true); api.release.TranslateDescription(desc) .then((en) => { setTranslating(false); if (en && en !== desc) setDesc(en); else toast("Перевод не сработал (модель недоступна?)", "warn"); }) .catch((e) => { setTranslating(false); toast("Перевод не удался: " + e.message, "error"); }); }; const generate = () => { if (!selectedIDs.length) return; setBusy(true); setResults(null); const items = selectedIDs.map((id) => ({ ModelID: id, Description: desc })); api.release.Generate(dateISO, items) .then((res) => { setBusy(false); const arr = res || []; setResults(arr); const ok = arr.filter((r) => !r.Error).length; const fail = arr.filter((r) => r.Error).length; if (ok) toast(`Создано релизов: ${ok}` + (fail ? `, с ошибкой: ${fail}` : ""), fail ? "warn" : "ok"); else if (fail) toast(`Не удалось: ${fail}`, "error"); onReload && onReload(); }) .catch((e) => { setBusy(false); toast("Ошибка генерации: " + e.message, "error"); }); }; if (models === null) return
загрузка моделей…
; if (models.length === 0) return
нет готовых моделей (нужны бланк и фото) — добавь на экране «Релизы»
; const nameByID = {}; models.forEach((m) => { nameByID[m.ID] = m.Surname + " " + m.GivenName; }); return (
дата: {dateISO} · выбери модели (одно описание на всех)
{models.map((m) => ( ))}
setDesc((d) => (d ? d + " " : "") + t)} title="Надиктовать (можно по-русски)"/> EN напрямую или RU → «Перевести»