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);
}
}