Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
// ---------- Администрирование сервера: резервные копии ----------
|
||||
const mbx = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " МБ" : Math.max(1, Math.round(n / 1024)) + " КБ";
|
||||
|
||||
async function backupsPage() {
|
||||
if (!me.is_admin) throw new Error("Только для администратора");
|
||||
const j = await api("GET", "/backups");
|
||||
const reload = () => backupsPage();
|
||||
const media = h("input", { type: "checkbox" }), note = h("input", { placeholder: "Комментарий, например «перед обновлением»" });
|
||||
const auto = h("input", { type: "number", min: "0", max: "720", value: j.auto_hours, style: "max-width:120px" });
|
||||
const keep = h("input", { type: "number", min: "1", max: "365", value: j.keep, style: "max-width:120px" });
|
||||
const amedia = h("input", { type: "checkbox", checked: j.media });
|
||||
const upload = h("input", { type: "file", accept: ".zip", style: "display:none", onchange: guard(async e => {
|
||||
const f = e.target.files[0]; if (!f) return;
|
||||
toast("Загрузка копии…");
|
||||
const r = await authFetch(API + "/backups/upload", { method: "POST", headers: { "Content-Type": "application/zip" }, body: f });
|
||||
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
|
||||
toast("Копия загружена"); reload();
|
||||
}) });
|
||||
async function download(name) {
|
||||
const r = await authFetch(`${API}/backups/${encodeURIComponent(name)}`);
|
||||
if (!r.ok) throw new Error("Не удалось скачать копию");
|
||||
const a = h("a", { href: URL.createObjectURL(await r.blob()), download: name });
|
||||
document.body.append(a); a.click(); a.remove();
|
||||
}
|
||||
fill($app, h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Резервные копии"),
|
||||
me.demo_mode ? h("div", { class: "card", style: "border-color:var(--warn)" }, "Вы сейчас в демо-режиме: копировать и восстанавливать рабочую базу можно только после выхода из демо.") : null,
|
||||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Создать копию"),
|
||||
h("p", { class: "small muted" }, "В копию входят вся продуктовая база (пользователи, оборудование, наряды, задания, склады, инциденты…) и конфигурация: настройки сервера и несекретные параметры окружения. ",
|
||||
"Пароли, SECRET_KEY и строка подключения к базе в копию не попадают — только их названия."),
|
||||
h("div", { class: "row" }, h("div", { style: "flex:1;min-width:260px" }, note),
|
||||
h("label", { class: "chk" }, media, "включить фото и картинки (файл будет больше)"),
|
||||
h("button", { onclick: guard(async () => { toast("Создаю копию…"); const b = await api("POST", "/backups", { include_media: media.checked, note: note.value }); toast("Копия создана: " + mbx(b.size)); reload(); }) }, "Создать копию"))),
|
||||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Автоматические копии"),
|
||||
h("div", { class: "grid" },
|
||||
field("Делать копию каждые, часов (0 — выключено)", auto), field("Сколько автокопий хранить", keep),
|
||||
h("div", {}, h("label", {}, "Состав"), h("label", { class: "chk" }, amedia, "с фото и картинками"))),
|
||||
h("p", { class: "small muted" }, j.next_auto ? `Следующая автокопия: ${fmt(j.next_auto)}. ` : "Автокопии выключены. ", `Каталог на сервере: ${j.folder}. В Docker это том appdata — копируйте файлы и за пределы сервера.`),
|
||||
h("button", { class: "sec", onclick: guard(async () => {
|
||||
await api("PUT", "/settings", { backup_auto_hours: +auto.value, backup_keep: +keep.value, backup_media: amedia.checked }); toast("Сохранено"); reload();
|
||||
}) }, "Сохранить")),
|
||||
h("div", { class: "card" }, h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, `Копии (${j.items.length})`), upload,
|
||||
h("button", { class: "sec sm", onclick: () => upload.click() }, "⬆ Загрузить файл копии")),
|
||||
j.items.length ? h("div", { class: "tablewrap" }, h("table", { style: "margin-top:8px" }, h("thead", {}, h("tr", {}, ...["Создана", "Комментарий", "Размер", "Записей", "Фото", ""].map(t => h("th", {}, t)))),
|
||||
h("tbody", {}, ...j.items.map(b => h("tr", {},
|
||||
h("td", {}, fmt(b.created_at), h("div", { class: "small muted" }, b.name)), h("td", {}, b.note || "—"), h("td", {}, mbx(b.size)),
|
||||
h("td", {}, b.rows), h("td", {}, b.media ? "✔" : "—"),
|
||||
h("td", { class: "row end" },
|
||||
h("button", { class: "sec sm", onclick: guard(() => download(b.name)) }, "⬇ Скачать"),
|
||||
h("button", { class: "sec sm", onclick: guard(async () => {
|
||||
if (!confirm(`Восстановить данные из копии от ${fmt(b.created_at)}?\n\nВСЕ текущие данные будут заменены содержимым копии. Перед этим текущее состояние автоматически сохранится отдельной копией.`)) return;
|
||||
if (prompt("Для подтверждения введите слово ВОССТАНОВИТЬ") !== "ВОССТАНОВИТЬ") return;
|
||||
const r = await api("POST", `/backups/${encodeURIComponent(b.name)}/restore`, { confirm: true });
|
||||
alert(`Восстановлено записей: ${r.rows}${r.media_files ? `, файлов: ${r.media_files}` : ""}.\nКопия состояния до восстановления: ${r.safety_copy}.\nВойдите заново.`);
|
||||
logout();
|
||||
}) }, "↺ Восстановить"),
|
||||
h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить файл копии?")) { await api("DELETE", "/backups/" + encodeURIComponent(b.name)); reload(); } }) }, "✕"))))))) :
|
||||
h("p", { class: "muted" }, "Копий пока нет")));
|
||||
}
|
||||
|
||||
// ---------- AI-помощник: настройки (вызывается со страницы «Настройки сервера») ----------
|
||||
async function assistantSettings(box) {
|
||||
const c = await api("GET", "/assistant/config");
|
||||
const st = { provider: c.provider || "openai" };
|
||||
const enabled = h("input", { type: "checkbox", checked: c.enabled });
|
||||
const prov = h("select", { onchange: e => { st.provider = e.target.value; url.placeholder = c.default_urls[st.provider] || ""; hint.replaceChildren(...hints()); } },
|
||||
...Object.entries(c.providers).map(([k, t]) => h("option", { value: k, selected: k === st.provider }, t)));
|
||||
const url = h("input", { value: c.base_url, placeholder: c.default_urls[st.provider] || "" });
|
||||
const model = h("input", { value: c.model, placeholder: "например: yandexgpt-lite, gpt-4o-mini, GigaChat, claude-haiku-…" });
|
||||
const key = h("input", { type: "password", autocomplete: "new-password", placeholder: c.api_key_set ? `ключ задан (${c.api_key_hint}) — оставьте пустым, чтобы не менять` : "ключ API провайдера" });
|
||||
const scope = h("textarea", { placeholder: "Необязательно: дополнительные указания помощнику, например «о закупках — к Козлову Д. О., тел. 123»" }, c.scope);
|
||||
const out = h("div"), hint = h("div", { class: "small muted" });
|
||||
const hints = () => ({
|
||||
openai: ["Подходит для OpenAI, YandexGPT (адрес https://llm.api.cloud.yandex.net/v1, модель gpt://<каталог>/yandexgpt-lite/latest, ключ — API-ключ сервисного аккаунта), DeepSeek, OpenRouter и локальных моделей (Ollama: http://localhost:11434/v1, ключ не нужен)."],
|
||||
anthropic: ["Anthropic: адрес можно не указывать, модель — например claude-haiku-4-5, ключ — sk-ant-…"],
|
||||
gigachat: ["GigaChat: адрес можно не указывать, модель — GigaChat, ключ — «Ключ авторизации» из личного кабинета Сбера (Base64)."],
|
||||
})[st.provider];
|
||||
hint.replaceChildren(...hints());
|
||||
const body = () => { const b = { enabled: enabled.checked, provider: st.provider, base_url: url.value.trim(), model: model.value.trim(), scope: scope.value };
|
||||
if (key.value) b.api_key = key.value; return b; };
|
||||
box.replaceChildren(h("h2", { style: "margin-top:0" }, "🤖 AI-помощник"),
|
||||
h("p", { class: "small muted" }, "Кнопка помощника появится у всех пользователей в правом нижнем углу. Помощник отвечает только о работе в портале (остальное вежливо отклоняет), ",
|
||||
"учитывает права пользователя и читает данные только от его имени, ничего не меняет. В провайдер уходят вопрос, справка по порталу и найденные для ответа данные пользователя. Лимит — 30 вопросов в час на человека."),
|
||||
h("div", { class: "grid" }, field("Провайдер", prov), field("Адрес API", url), field("Модель", model), field("Ключ API", key)), hint,
|
||||
h("div", { style: "margin-top:8px" }, field("Указания помощнику", scope)),
|
||||
h("div", { class: "row", style: "margin-top:10px" }, h("label", { class: "chk" }, enabled, "Помощник включён"), h("span", { style: "flex:1" }),
|
||||
h("button", { class: "sec", onclick: guard(async () => {
|
||||
await api("PUT", "/assistant/config", body()); key.value = "";
|
||||
toast("Проверяю…"); const r = await api("POST", "/assistant/test");
|
||||
out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, h("b", {}, "Подключение работает. "),
|
||||
r.offtopic_filtered ? "Фильтр тематики работает. " : h("span", { style: "color:var(--warn)" }, "Модель не отклонила вопрос не по теме — выберите модель поумнее. "),
|
||||
h("div", { class: "small", style: "white-space:pre-wrap;margin-top:4px" }, "Пример ответа: " + r.answer)));
|
||||
window.assistantRefresh && window.assistantRefresh();
|
||||
}) }, "Проверить"),
|
||||
h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")),
|
||||
out);
|
||||
}
|
||||
Reference in New Issue
Block a user