// web/screens/releases.jsx — раздел «Релизы» (Фаза 3, §16.1).
// Стилистика — как в Ревью 1: page / card / btn-варианты / pill / label.
// Ключи ответов капитализированы (Go-JSON): Adult/Child/Property, Surname, Years…
const REL_BLOCKS = [
["adult", "Взрослые", "Adult"],
["child", "Детские", "Child"],
["property", "Проперти", "Property"],
];
function ReleasesScreen({ setRoute }) {
const [, toast] = useToasts();
const [tab, setTab] = React.useState("models");
const [data, setData] = React.useState({});
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const [report, setReport] = React.useState(null);
const [error, setError] = React.useState(null);
const load = React.useCallback(() => {
setLoading(true);
api.release.ListModels()
.then((d) => { setData(d || {}); setLoading(false); })
.catch((e) => { setData({}); setLoading(false); toast("Не удалось загрузить модели: " + e.message, "error"); });
}, []);
React.useEffect(() => { load(); }, [load]);
const refresh = () => {
setRefreshing(true);
setError(null);
api.release.RefreshTemplates()
.then((r) => {
setReport(r || {});
setRefreshing(false);
load();
const n = (r && r.Downloaded && r.Downloaded.length) || 0;
toast(n > 0 ? `Обновлено, подтянул ${n} файл(ов)` : "Обновлено", "ok");
})
.catch((e) => { setRefreshing(false); setError(e.message || String(e)); toast("Ошибка обновления: " + e.message, "error"); });
};
const total = REL_BLOCKS.reduce((n, [, , key]) => n + (data[key] || []).length, 0);
return (
Релизы
{[["models", "Модели"], ["templates", "Шаблоны"]].map(([id, label]) => (
setTab(id)}>{label}
))}
{tab === "models" &&
{total} модел. }
{refreshing ? <> Обновляю…> : <>↻ Обновить из Яндекса>}
{error && (
Ошибка обновления: {error}
Проверь, что файлы на Яндексе в _Pikasrv/_Releases/templates/ и Я.Диск подключён.
)}
{report &&
}
{loading ? (
загружаем…
) : tab === "models" ? (
) : (
)}
);
}
function RefreshReport({ report }) {
const probs = [];
(report.MissingMasks || []).forEach((t) => probs.push("нет маски типа «" + t + "»"));
(report.ModelsWithoutBlank || []).forEach((m) => probs.push("нет бланка: " + m));
(report.ModelsWithoutPhoto || []).forEach((m) => probs.push("нет фото: " + m));
return (
Обновление из Яндекса
подтянул {(report.Downloaded || []).length}
моделей {report.Models || 0}
{(report.Removed || []).length > 0 && удалил {report.Removed.length} }
{probs.length > 0 && (
{probs.map((p, i) => {p} )}
)}
{(report.Failed || []).length > 0 && (
⚠ не удалось: {report.Failed.join("; ")}
)}
{(report.Ignored || []).length > 0 && (
пропущены (имя не подошло): {report.Ignored.join(", ")}
)}
);
}
function ModelsTab({ data, onGenerated }) {
data = data || {};
const empty = REL_BLOCKS.every(([, , key]) => !(data[key] || []).length);
if (empty) {
return (
Пока нет моделей. Положите шаблоны на Яндекс в _Releases/templates/
{" "}(бланк adult_Фамилия_Имя.jpg , фото
{" "}adult_Фамилия_Имя_2026.jpg , маска adult_mask.png )
{" "}и нажмите «Обновить из Яндекса».
);
}
return (
{REL_BLOCKS.map(([type, title, key]) => {
const models = data[key] || [];
if (!models.length) return null;
return (
{title} · {models.length}
{models.map((m) => )}
);
})}
);
}
function todayISO() { return new Date().toISOString().slice(0, 10); }
function ModelRow({ model, onGenerated }) {
const [, toast] = useToasts();
const [open, setOpen] = React.useState(false);
const [date, setDate] = React.useState(todayISO());
const [desc, setDesc] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [result, setResult] = React.useState(null);
const [translating, setTranslating] = React.useState(false);
const [releases, setReleases] = React.useState(null);
const canGen = (model.Years || []).length && model.HasBlank;
const loadReleases = React.useCallback(() => {
api.release.ListByModel(model.ID).then((r) => setReleases(r || [])).catch(() => setReleases([]));
}, [model.ID]);
React.useEffect(() => { loadReleases(); }, [loadReleases]);
const del = (id) => {
api.release.Delete(id)
.then(() => { loadReleases(); onGenerated && onGenerated(); toast("Релиз удалён", "ok"); })
.catch((e) => toast("Не удалось удалить: " + e.message, "error"));
};
const delModel = () => {
if (!window.confirm(`Удалить модель «${model.Surname} ${model.GivenName}» из справочника? (для чистки опечаток; готовые релизы не трогаются)`)) return;
api.release.DeleteModel(model.ID)
.then(() => { onGenerated && onGenerated(); toast("Модель удалена", "ok"); })
.catch((e) => toast("Не удалось удалить модель: " + e.message, "error"));
};
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 = () => {
setBusy(true); setResult(null);
api.release.Generate(date, [{ ModelID: model.ID, Description: desc }])
.then((res) => {
const r = res && res[0];
setResult(r);
setBusy(false);
if (r && !r.Error) { toast("Релиз создан: " + r.OutputName, "ok"); loadReleases(); onGenerated && onGenerated(); }
})
.catch((e) => { setBusy(false); toast("Ошибка генерации: " + e.message, "error"); });
};
return (
{model.Surname} {model.GivenName}
{(model.Years || []).length
? (model.Years || []).map((y) => {y} )
: нет фото }
{!model.HasBlank && нет бланка }
Удалить
setOpen(!open)}>
{open ? "Свернуть" : "Создать релиз"}
{releases && releases.length > 0 && (
Готовые релизы
{releases.map((r) => (
{r.Date}
{r.OutputName}
{r.UsedNearestYear &&
фото за {r.UsedYear} (другой год) }
del(r.ID)}>Удалить
))}
)}
{open && (
Создать релиз
Описание
setDesc((d) => (d ? d + " " : "") + t)} title="Надиктовать (можно по-русски)" />
{translating ? <> перевожу…> : "Перевести на английский"}
EN напрямую или RU → «Перевести»
{busy ? <> Генерирую…> : "Сгенерировать релиз"}
{!canGen && нужны бланк и фото }
{result && (result.Error
? {result.Error}
:
{result.OutputName}{result.UsedNearestYear ? ` · фото за ${result.UsedYear} (другой год)` : ""}
)}
)}
);
}
function TemplatesTab({ report, onRefresh }) {
return (
Шаблоны лежат на Яндексе в _Releases/templates/ :
маски по типу: adult_mask.png , child_mask.png , property_mask.png (PNG)
бланки моделей: тип_Фамилия_Имя.jpg
фото по годам: тип_Фамилия_Имя_ГГГГ.jpg
{report ? (
{(report.MissingMasks || []).length
? report.MissingMasks.map((t) => нет маски «{t}» )
: маски на месте }
{(report.ModelsWithoutBlank || []).length > 0 && без бланка: {report.ModelsWithoutBlank.length} }
{(report.ModelsWithoutPhoto || []).length > 0 && без фото: {report.ModelsWithoutPhoto.length} }
) : (
Обновить из Яндекса, чтобы увидеть статус
)}
);
}