diff --git a/VERSION b/VERSION index 6067817..297b2ec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.133 +0.0.134 diff --git a/frontend/static/app.js b/frontend/static/app.js index d7a9bf1..c4b2ca3 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -1180,13 +1180,21 @@ const CamSettings = { }, enterBulk() { - if (!this.cams.length) { alert("Нет камер для редактирования"); return; } - this.bulk = true; this.editing = true; + this.bulk = true; this.editing = true; // пустые слоты тоже редактируемы → можно с 0 камер this.setBulkButtons(); this.setBulkHeaders(true); this.load(); }, + // наименьший свободный id вида camN (id скрыт от пользователя, нужен как ключ записи/потока) + nextCamId(used) { + for (let n = 1; n <= 999; n++) { + const id = "cam" + n; + if (!used.has(id)) { used.add(id); return id; } + } + return "cam" + (used.size + 1); + }, + async exitBulk() { this.bulk = false; this.editing = false; this.setBulkButtons(); @@ -1212,20 +1220,29 @@ const CamSettings = { }, // ── строка камеры в режиме массового редактирования: те же колонки, но ячейки — - // редактируемые инпуты (НЕ отдельная форма). Статус-колонка на время правки = логин/пароль. + // редактируемые инпуты (НЕ отдельная форма). cam=null → пустой слот для новой камеры. + // Статус-колонка на время правки = логин/пароль. bulkRowHtml(slotNo, cam) { const v = (x) => this.esc(x); + const name = cam ? v(cam.name) : ""; + const ip = cam ? v(cam.ip) : ""; + const user = cam ? v(cam.user) : "admin"; + const password = cam ? v(cam.password) : ""; + const ch = cam ? channelFromPath(cam.main_path) : 1; + const enabled = cam ? cam.enabled : true; + const record = cam ? cam.record : true; + const storageId = cam ? cam.storage_id : ""; return ( `${slotNo}` + - `` + - `` + - `` + - `` + - `` + - `` + + `` + + `` + + `` + + `` + + `` + + `` + `` + - `` + - `` + + `` + + `` + `` + `` ); @@ -1240,37 +1257,49 @@ const CamSettings = { }, async saveBulk() { - const rows = document.querySelectorAll("#cam-rows tr[data-id]"); + const rows = document.querySelectorAll("#cam-rows tr.bulk-row"); const errors = []; + const used = new Set(this.cams.map((c) => c.id)); // занятые id (для генерации новых) for (const tr of rows) { - const id = tr.dataset.id; - const orig = this.byId[id] || {}; const get = (f) => { const el = tr.querySelector(`[data-f="${f}"]`); return el ? el.value : ""; }; + const ch = +get("channel") || 1; const payload = { name: get("name").trim(), ip: get("ip").trim(), user: get("user").trim(), password: get("password"), - main_path: mainPathForChannel(+get("channel") || 1), - sub_path: subPathForChannel(+get("channel") || 1), + main_path: mainPathForChannel(ch), + sub_path: subPathForChannel(ch), storage_id: get("storage_id"), enabled: tr.querySelector('[data-f="enabled"]').checked, record: tr.querySelector('[data-f="record"]').checked, }; - const changed = - payload.name !== orig.name || payload.ip !== orig.ip || - payload.user !== orig.user || payload.password !== (orig.password || "") || - payload.main_path !== orig.main_path || - (payload.sub_path || "") !== (orig.sub_path || "") || - (payload.storage_id || "") !== (orig.storage_id || "") || - payload.enabled !== orig.enabled || - payload.record !== orig.record; - if (!changed) continue; - const r = await fetch(`/api/cameras/${id}`, { - method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!r.ok) { const d = await r.json().catch(() => ({})); errors.push(`${id}: ${d.detail || r.status}`); } + if (tr.dataset.id) { // существующая камера → PUT только при изменении + const id = tr.dataset.id; + const orig = this.byId[id] || {}; + const changed = + payload.name !== orig.name || payload.ip !== orig.ip || + payload.user !== orig.user || payload.password !== (orig.password || "") || + payload.main_path !== orig.main_path || + (payload.sub_path || "") !== (orig.sub_path || "") || + (payload.storage_id || "") !== (orig.storage_id || "") || + payload.enabled !== orig.enabled || + payload.record !== orig.record; + if (!changed) continue; + const r = await fetch(`/api/cameras/${id}`, { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!r.ok) { const d = await r.json().catch(() => ({})); errors.push(`${id}: ${d.detail || r.status}`); } + } else { // пустой слот → создаём камеру, если заполнено имя/IP + if (!payload.name && !payload.ip) continue; + const id = this.nextCamId(used); + const r = await fetch("/api/cameras", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, ...payload }), + }); + if (!r.ok) { const d = await r.json().catch(() => ({})); errors.push(`слот ${tr.dataset.slot}: ${d.detail || r.status}`); } + } } this.bulk = false; this.editing = false; this.setBulkButtons(); @@ -1376,14 +1405,15 @@ const CamSettings = { ); tb.innerHTML = ""; if (this.bulk) { - // массовое редактирование: те же строки/колонки, но ячейки — редактируемые инпуты - cams.forEach((c, i) => { + // массовое редактирование: ВСЕ 64 слота сразу редактируемые (пустые — для добавления) + for (let i = 0; i < this.SLOTS; i++) { + const c = cams[i] || null; const tr = document.createElement("tr"); tr.className = "bulk-row"; - tr.dataset.id = c.id; + if (c) tr.dataset.id = c.id; else tr.dataset.slot = i + 1; tr.innerHTML = this.bulkRowHtml(i + 1, c); tb.appendChild(tr); - }); + } return; } for (let i = 0; i < this.SLOTS; i++) { @@ -1423,7 +1453,7 @@ const CamSettings = { `${i + 1}` + `——————` + `пусто` + - ``; + ``; tb.appendChild(tr); } }