- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
156 lines
16 KiB
JavaScript
156 lines
16 KiB
JavaScript
"use strict";
|
|
// ---------- Документация машин из Nextcloud ----------
|
|
// Nextcloud сотрудникам не виден: папки и файлы отдаёт портал. PDF, картинки и текст открываются прямо здесь,
|
|
// документы Office — после перевода в PDF на сервере ONLYOFFICE. Привязывают папки администратор и модератор.
|
|
const DOC_ICON = { dir: "📁", pdf: "📕", image: "🖼", text: "📄", office: "📝", file: "📎" };
|
|
const docSize = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " МБ" : Math.max(1, Math.round(n / 1024)) + " КБ";
|
|
const docUrl = (id, path, view) => `${API}/equipment/${id}/docs/file?` + new URLSearchParams({ path, ...(view ? { view: 1 } : {}) });
|
|
const hashQuery = () => new URLSearchParams((location.hash.split("?")[1]) || "");
|
|
|
|
// блок в карточке машины
|
|
function docsBlock(eq, reload) {
|
|
if (!me.docs || (!eq.docs_path && !isMgr())) return null;
|
|
return h("div", { class: "card" },
|
|
h("div", { class: "row" }, h("h2", { style: "flex:1;margin:0" }, "📁 Документация"),
|
|
eq.docs_path ? h("a", { class: "btn sm", href: "#/docs/" + eq.id }, "Открыть") : null,
|
|
isMgr() ? h("button", { class: "sec sm", onclick: () => docsPicker(eq, reload) }, eq.docs_path ? "Сменить папку" : "Привязать папку") : null),
|
|
h("div", { class: "small muted", style: "margin-top:4px" }, eq.docs_path ? "Папка: " + eq.docs_path
|
|
: "Папка с документацией ещё не привязана. Выберите её в проводнике — сотрудники будут открывать документы прямо в портале."));
|
|
}
|
|
|
|
// проводник для привязки папки
|
|
async function docsPicker(eq, done) {
|
|
if (document.querySelector(".dcmodal")) return;
|
|
let path = eq.docs_path ? eq.docs_path.split("/").slice(0, -1).join("/") : "";
|
|
const body = h("div"), close = () => wrap.remove();
|
|
const wrap = h("div", { class: "dcmodal", onclick: e => { if (e.target === wrap) close(); } }, h("div", { class: "card" }, body));
|
|
const save = guard(async p => {
|
|
await api("PUT", `/equipment/${eq.id}/docs`, { path: p });
|
|
delete cache.equipment; close(); toast(p ? "Папка привязана" : "Папка отвязана"); done();
|
|
});
|
|
async function draw() {
|
|
body.replaceChildren(h("p", { class: "muted" }, "Загрузка…"));
|
|
let d;
|
|
try { d = await api("GET", "/docs/browse?" + new URLSearchParams({ path })); }
|
|
catch (e) { body.replaceChildren(h("h2", { style: "margin-top:0" }, "Документация"), h("p", { class: "error" }, e.message), h("button", { class: "sec", onclick: close }, "Закрыть")); return; }
|
|
const parts = d.path ? d.path.split("/") : [];
|
|
const crumbs = h("div", { class: "crumbs" }, h("a", { href: "javascript:void 0", onclick: () => { path = ""; draw(); } }, "Документация"),
|
|
...parts.flatMap((p, i) => [" / ", h("a", { href: "javascript:void 0", onclick: () => { path = parts.slice(0, i + 1).join("/"); draw(); } }, p)]));
|
|
body.replaceChildren(h("h2", { style: "margin-top:0" }, "Папка документации: " + eq.name), crumbs,
|
|
h("div", { class: "dclist" }, ...(d.items.length ? d.items.map(x => h("div", { class: "dcrow" },
|
|
h("a", { href: "javascript:void 0", style: "flex:1", onclick: () => { path = x.path; draw(); } }, "📁 " + x.name),
|
|
x.linked.length ? h("span", { class: "small muted" }, "привязана: " + x.linked.join(", ")) : null,
|
|
h("button", { class: "sm", onclick: () => save(x.path) }, "Выбрать"))) : [h("p", { class: "muted small" }, "Вложенных папок нет")])),
|
|
h("div", { class: "small muted", style: "margin-top:6px" }, `В этой папке файлов: ${d.files}`),
|
|
h("div", { class: "row", style: "margin-top:12px" },
|
|
d.path ? h("button", { onclick: () => save(d.path) }, "Привязать эту папку") : null,
|
|
eq.docs_path ? h("button", { class: "sec", onclick: () => confirm("Отвязать папку от машины?") && save("") }, "Отвязать") : null,
|
|
h("span", { style: "flex:1" }), h("button", { class: "sec", onclick: close }, "Отмена")));
|
|
}
|
|
document.body.append(wrap);
|
|
draw();
|
|
}
|
|
|
|
// страница документации машины: #/docs/ID?d=папка или ?p=файл
|
|
async function docsPage(id) {
|
|
const q = hashQuery(), file = q.get("p"), sub = q.get("d") || (file ? file.split("/").slice(0, -1).join("/") : "");
|
|
const eq = await api("GET", "/equipment/" + id);
|
|
const d = await api("GET", `/equipment/${id}/docs?` + new URLSearchParams({ sub }));
|
|
const viewer = h("div");
|
|
const go = (k, v) => `#/docs/${id}` + (v ? "?" + new URLSearchParams({ [k]: v }) : "");
|
|
const parts = sub ? sub.split("/") : [];
|
|
const row = x => h("a", { class: "dcrow" + (x.path === file ? " on" : ""), href: go(x.is_dir ? "d" : "p", x.path) },
|
|
h("span", { style: "flex:1" }, (DOC_ICON[x.kind] || "📎") + " " + x.name),
|
|
x.is_dir ? null : h("span", { class: "small muted" }, docSize(x.size) + (x.mtime ? " · " + fmtDate(x.mtime) : "")));
|
|
fill($app, h("a", { href: "#/equipment/" + id }, "← " + eq.name), h("h1", {}, "Документация"),
|
|
h("div", { class: "crumbs" }, h("a", { href: go() }, d.folder), ...parts.flatMap((p, i) => [" / ", h("a", { href: go("d", parts.slice(0, i + 1).join("/")) }, p)])),
|
|
h("div", { class: "dcwrap" }, h("div", { class: "card dclist" }, ...(d.items.length ? d.items.map(row) : [h("p", { class: "muted" }, "Папка пуста")])), viewer));
|
|
if (file) await docsView(id, d.items.find(x => x.path === file) || { path: file, name: file.split("/").pop(), kind: "file" }, viewer);
|
|
}
|
|
|
|
async function docsView(id, x, box) {
|
|
const dl = h("button", { class: "sec sm", onclick: guard(() => docsDownload(id, x)) }, "⬇ Скачать");
|
|
const head = (...extra) => h("div", { class: "row", style: "margin-bottom:8px" }, h("b", { style: "flex:1" }, x.name), ...extra, dl);
|
|
if (x.kind === "file") return box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "muted" }, "Этот формат в портале не открывается — скачайте файл.")));
|
|
box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "muted" }, x.kind === "office" ? "Готовлю документ к просмотру…" : "Загрузка…")));
|
|
const r = await authFetch(docUrl(id, x.path, true));
|
|
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).detail; } catch (_) {} return box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "error" }, m))); }
|
|
const blob = await r.blob(), url = URL.createObjectURL(blob);
|
|
const open = h("button", { class: "sec sm", onclick: () => window.open(url, "_blank", "noopener") }, "↗ В новой вкладке");
|
|
const view = x.kind === "image" ? h("img", { src: url, alt: x.name, class: "dcimg" })
|
|
: x.kind === "text" ? h("pre", { class: "dctext" }, await blob.text())
|
|
: h("iframe", { src: url, class: "dcframe", title: x.name });
|
|
box.replaceChildren(h("div", { class: "card" }, head(open), view));
|
|
}
|
|
|
|
async function docsDownload(id, x) {
|
|
const r = await authFetch(docUrl(id, x.path, false));
|
|
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).detail; } catch (_) {} throw new Error(m); }
|
|
const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob()); a.download = x.name;
|
|
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
}
|
|
|
|
// настройки сервера: подключение к Nextcloud и ONLYOFFICE
|
|
async function docsSettings(box) {
|
|
const c = await api("GET", "/docs/config");
|
|
const inp = (k, ph, type) => h("input", { value: type === "password" ? "" : c[k] || "", placeholder: ph, ...(type ? { type, autocomplete: "new-password" } : {}) });
|
|
const f = {
|
|
nc_url: inp("nc_url", "http://192.168.1.30 или https://cloud.example.ru"), nc_user: inp("nc_user", "служебная учётная запись, например portal-docs"),
|
|
nc_password: inp("nc_password", c.nc_password_set ? "пароль задан — оставьте пустым, чтобы не менять" : "пароль приложения", "password"),
|
|
nc_root: inp("nc_root", "папка с документацией, например Документация/Оборудование"),
|
|
onlyoffice_url: inp("onlyoffice_url", "http://192.168.1.31 — Document Server в локальной сети"),
|
|
onlyoffice_jwt: inp("onlyoffice_jwt", c.onlyoffice_jwt_set ? "секрет задан — оставьте пустым, чтобы не менять" : "секрет JWT Document Server (если включён)", "password"),
|
|
portal_internal_url: inp("portal_internal_url", "http://192.168.1.20:8000 — адрес портала, видимый для ONLYOFFICE"),
|
|
};
|
|
const out = h("div");
|
|
const body = () => { const b = {}; for (const [k, el] of Object.entries(f)) if (!(el.type === "password" && !el.value)) b[k] = el.value.trim(); return b; };
|
|
box.replaceChildren(h("h2", { style: "margin-top:0" }, "📁 Документация из Nextcloud"),
|
|
h("p", { class: "small muted" }, "Портал читает папки Nextcloud от служебной учётной записи и сам отдаёт документы сотрудникам — Nextcloud им не нужен и не виден. ",
|
|
"Заведите в Nextcloud отдельного пользователя, дайте ему доступ ", h("b", {}, "только на чтение"), " к папке с документацией и создайте для него пароль приложения ",
|
|
"(Настройки → Безопасность → «Создать пароль приложения»). Адрес лучше указать внутренний, в локальной сети."),
|
|
h("div", { class: "grid" }, field("Адрес Nextcloud", f.nc_url), field("Учётная запись", f.nc_user), field("Пароль приложения", f.nc_password), field("Корневая папка", f.nc_root)),
|
|
h("h3", {}, "Просмотр документов Office (ONLYOFFICE)"),
|
|
h("p", { class: "small muted" }, "Word, Excel и PowerPoint сервер ONLYOFFICE переводит в PDF, и документ открывается прямо в портале. Без ONLYOFFICE такие файлы можно только скачать. ",
|
|
"Внутренний адрес портала нужен, чтобы ONLYOFFICE забрал исходник по одноразовой ссылке."),
|
|
h("div", { class: "grid" }, field("Адрес ONLYOFFICE", f.onlyoffice_url), field("Секрет JWT", f.onlyoffice_jwt), field("Внутренний адрес портала", f.portal_internal_url)),
|
|
h("div", { class: "row", style: "margin-top:10px" }, h("span", { style: "flex:1" }),
|
|
h("button", { class: "sec", onclick: guard(async () => {
|
|
await api("PUT", "/docs/config", body()); toast("Проверяю…");
|
|
const r = await api("POST", "/docs/test");
|
|
out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, h("div", {}, h("b", {}, "Nextcloud: "), r.nextcloud),
|
|
h("div", {}, h("b", {}, "ONLYOFFICE: "), r.onlyoffice || "не указан — документы Office только скачиваются")));
|
|
}) }, "Проверить"),
|
|
h("button", { onclick: guard(async () => { await api("PUT", "/docs/config", body()); me = await api("GET", "/me"); toast("Сохранено"); docsSettings(box); }) }, "Сохранить")),
|
|
out,
|
|
h("p", { class: "small muted" }, `Привязано машин: ${c.index.linked}. Папку к машине привязывают администратор или модератор в карточке машины — блок «Документация».`));
|
|
}
|
|
|
|
// опция AI-помощника «Работа с документацией» (в карточке помощника)
|
|
async function docsAiSection() {
|
|
const c = await api("GET", "/docs/config");
|
|
const on = h("input", { type: "checkbox", checked: c.docs_ai });
|
|
const ix = c.index, box = h("div", { class: "docsai" });
|
|
const status = ix.running ? `строится: прочитано ${ix.done} файлов…` : c.docs_index_at ? `в индексе ${ix.files} файлов, обновлён ${fmt(c.docs_index_at)}` : "ещё не строился";
|
|
box.replaceChildren(h("h3", {}, "📚 Работа с документацией"),
|
|
h("label", { class: "chk" }, on, "Помощник ищет ответы в документации машин и подсказывает, какой файл открыть"),
|
|
h("div", { class: "sheet risk" }, h("b", {}, "Риски — прочитайте перед включением"),
|
|
h("ul", {},
|
|
h("li", {}, h("b", {}, "Передача данных провайдеру ИИ. "), "В запрос к модели уходят имена файлов и фрагменты текста документов (паспорта, схемы, регламенты). ",
|
|
"С облачным провайдером это значит, что содержимое документации покидает предприятие. Для документов с ограниченным доступом используйте локальную модель (Ollama, LM Studio в своей сети)."),
|
|
h("li", {}, h("b", {}, "Большой расход токенов. "), "Каждый вопрос о документации добавляет к запросу результаты поиска — запросы становятся в несколько раз длиннее и дороже. ",
|
|
"У платного провайдера следите за расходом; у локальной модели ответы будут медленнее."),
|
|
h("li", {}, h("b", {}, "Неточность. "), "Помощник видит только начало каждого документа и подсказывает, где искать, — окончательный ответ проверяйте в самом документе."),
|
|
h("li", {}, h("b", {}, "Нагрузка. "), "Раз в сутки портал перечитывает изменившиеся файлы из Nextcloud, чтобы обновить индекс. Сканы без текстового слоя не индексируются — находятся только по имени."))),
|
|
h("div", { class: "small muted", style: "margin-top:6px" }, "Индекс: " + status, ix.error ? h("div", { class: "error" }, ix.error) : null),
|
|
h("div", { class: "row", style: "margin-top:8px" },
|
|
h("button", { onclick: guard(async () => {
|
|
if (on.checked && !c.docs_ai && !confirm("Включить «Работа с документацией»? Фрагменты документов будут уходить провайдеру ИИ.")) return;
|
|
await api("PUT", "/docs/config", { docs_ai: on.checked });
|
|
if (on.checked && !c.docs_index_at) await api("POST", "/docs/reindex");
|
|
toast("Сохранено"); box.replaceWith(await docsAiSection());
|
|
}) }, "Сохранить"),
|
|
c.docs_ai ? h("button", { class: "sec", onclick: guard(async () => { await api("POST", "/docs/reindex"); toast("Индекс обновляется в фоне"); setTimeout(async () => box.replaceWith(await docsAiSection()), 1500); }) }, "Обновить индекс") : null));
|
|
if (!me.docs) box.append(h("p", { class: "small muted" }, "Сначала подключите Nextcloud в разделе «Документация из Nextcloud» выше."));
|
|
return box;
|
|
}
|