// 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]) => ( ))}
{tab === "models" && {total} модел.}
{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 && нет бланка}
{releases && releases.length > 0 && (
Готовые релизы
{releases.map((r) => (
{r.Date} {r.OutputName} {r.UsedNearestYear && фото за {r.UsedYear} (другой год)}
))}
)} {open && (
Создать релиз
setDate(e.target.value)} />
setDesc((d) => (d ? d + " " : "") + t)} title="Надиктовать (можно по-русски)" /> EN напрямую или RU → «Перевести»