ui: edit all 64 slots inline in bulk mode; drop per-row "+ Добавить"

"Изменить" now renders all 64 slots as editable rows — filled ones for editing,
empty ones with blank fields (user prefilled "admin", channel 1) for adding.
Saving PUTs changed cameras and POSTs filled empty slots (auto id camN, lowest
free). Removed the per-row "+ Добавить" buttons; empty slots in normal view just
show "пусто". Bulk edit can now be entered with zero cameras.

VERSION 0.0.134.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:47:24 +05:00
parent 1e6974acb8
commit ec41267444
2 changed files with 66 additions and 36 deletions
+65 -35
View File
@@ -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 (
`<td class="muted">${slotNo}</td>` +
`<td><input class="cell-in" type="text" data-f="name" value="${v(cam.name)}"></td>` +
`<td><input class="cell-in" type="text" data-f="ip" value="${v(cam.ip)}"></td>` +
`<td><select class="cell-sel" data-f="channel" title="Канал (поток)">${channelOptions(channelFromPath(cam.main_path))}</select></td>` +
`<td><input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></td>` +
`<td><input type="checkbox" data-f="record" ${cam.record ? "checked" : ""}></td>` +
`<td><select class="stor-sel" data-f="storage_id">${this.storageOptions(cam.storage_id)}</select></td>` +
`<td><input class="cell-in" type="text" data-f="name" value="${name}"${cam ? "" : ' placeholder="новая камера"'}></td>` +
`<td><input class="cell-in" type="text" data-f="ip" value="${ip}"${cam ? "" : ' placeholder="IP"'}></td>` +
`<td><select class="cell-sel" data-f="channel" title="Канал (поток)">${channelOptions(ch)}</select></td>` +
`<td><input type="checkbox" data-f="enabled" ${enabled ? "checked" : ""}></td>` +
`<td><input type="checkbox" data-f="record" ${record ? "checked" : ""}></td>` +
`<td><select class="stor-sel" data-f="storage_id">${this.storageOptions(storageId)}</select></td>` +
`<td class="cell-dual">` +
`<input class="cell-in" type="text" data-f="user" value="${v(cam.user)}" title="Логин">` +
`<input class="cell-in" type="password" data-f="password" value="${v(cam.password)}" title="Пароль" placeholder="пароль">` +
`<input class="cell-in" type="text" data-f="user" value="${user}" title="Логин">` +
`<input class="cell-in" type="password" data-f="password" value="${password}" title="Пароль" placeholder="пароль">` +
`</td>` +
`<td></td>`
);
@@ -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 = {
`<td class="muted">${i + 1}</td>` +
`<td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td>` +
`<td class="muted">пусто</td>` +
`<td class="actions"><button data-act="add">+ Добавить</button></td>`;
`<td></td>`;
tb.appendChild(tr);
}
}