ui: pick Hikvision channel (1-32) instead of typing full stream paths

Cameras (esp. an NVR at one IP with many channels) are configured by a channel
number now: channel N → main /Streaming/Channels/{N*100+1}, sub {N*100+2}
(101/102 = ch1, 1101/1102 = ch11, 3201/3202 = ch32). The add/edit form and the
bulk-edit row use a 1-32 dropdown; the camera list shows "канал N". Frontend
derives main_path/sub_path from the channel, so the backend is unchanged.

VERSION 0.0.132.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:29:40 +05:00
parent abc726b5a7
commit 20dfed0bae
3 changed files with 25 additions and 14 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.131
0.0.132
+22 -11
View File
@@ -106,6 +106,21 @@ function fmtUptime(s) {
const STATE_RU = { online: "запись", retrying: "переподключение", offline: "выключена", starting: "запуск" };
function dotClass(state) { return "dot " + (state || "offline"); }
// Канал Hikvision (1-32) ↔ пути RTSP: канал N → осн. /Streaming/Channels/{N*100+1},
// суб /Streaming/Channels/{N*100+2} (101/102 = канал 1, 1101/1102 = канал 11, 3201/3202 = 32).
function channelFromPath(p) {
const m = /(\d+)\s*$/.exec(p || "");
const ch = m ? Math.floor(parseInt(m[1], 10) / 100) : 1;
return ch >= 1 ? ch : 1;
}
function mainPathForChannel(ch) { return `/Streaming/Channels/${ch * 100 + 1}`; }
function subPathForChannel(ch) { return `/Streaming/Channels/${ch * 100 + 2}`; }
function channelOptions(sel) {
let h = "";
for (let i = 1; i <= 32; i++) h += `<option value="${i}"${i === sel ? " selected" : ""}>${i}</option>`;
return h;
}
// ── шапка: часы + статистика + выход ────────────────────────────
function startClock() {
const el = document.getElementById("clock");
@@ -1204,10 +1219,7 @@ const CamSettings = {
`<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 class="cell-dual">` +
`<input class="cell-in" type="text" data-f="main_path" value="${v(cam.main_path)}" title="Основной поток">` +
`<input class="cell-in" type="text" data-f="sub_path" value="${v(cam.sub_path)}" title="Субпоток" placeholder="суб.">` +
`</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>` +
@@ -1239,8 +1251,8 @@ const CamSettings = {
ip: get("ip").trim(),
user: get("user").trim(),
password: get("password"),
main_path: get("main_path").trim(),
sub_path: get("sub_path").trim(),
main_path: mainPathForChannel(+get("channel") || 1),
sub_path: subPathForChannel(+get("channel") || 1),
storage_id: get("storage_id"),
enabled: tr.querySelector('[data-f="enabled"]').checked,
record: tr.querySelector('[data-f="record"]').checked,
@@ -1281,8 +1293,7 @@ const CamSettings = {
`<label>IP<input type="text" id="e-ip" value="${cam ? v(cam.ip) : ""}" placeholder="192.168.63.x"></label>` +
`<label>Логин<input type="text" id="e-user" value="${cam ? v(cam.user) : "admin"}"></label>` +
`<label>Пароль<input type="password" id="e-pass" value="${cam ? v(cam.password) : ""}"></label>` +
`<label>Осн. поток<input type="text" id="e-main" value="${cam ? v(cam.main_path) : "/Streaming/Channels/101"}"></label>` +
`<label>Субпоток<input type="text" id="e-sub" value="${cam ? v(cam.sub_path) : "/Streaming/Channels/102"}" placeholder="можно пусто"></label>` +
`<label>Канал (поток)<select id="e-channel">${channelOptions(cam ? channelFromPath(cam.main_path) : 1)}</select></label>` +
`<label>Хранилище<select id="e-storage">${this.storageOptions(cam ? cam.storage_id : "")}</select></label>` +
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` +
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-record" ${(!cam || cam.record) ? "checked" : ""}></label>` +
@@ -1325,8 +1336,8 @@ const CamSettings = {
ip: val("e-ip").trim(),
user: val("e-user").trim(),
password: val("e-pass"),
main_path: val("e-main").trim(),
sub_path: val("e-sub").trim(),
main_path: mainPathForChannel(+val("e-channel") || 1),
sub_path: subPathForChannel(+val("e-channel") || 1),
storage_id: val("e-storage"),
enabled: document.getElementById("e-enabled").checked,
record: document.getElementById("e-record").checked,
@@ -1380,7 +1391,7 @@ const CamSettings = {
const tr = document.createElement("tr");
if (c) {
const st = (c.status && c.status.state) || "offline";
const streams = "осн. 101" + (c.has_sub ? " + суб. 102" : "");
const streams = "канал " + channelFromPath(c.main_path);
const statusCell = c.enabled
? `<span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span>`
: `<span class="muted">выключена</span>`;
+2 -2
View File
@@ -437,8 +437,8 @@ button.primary { background: var(--accent); border-color: #c0392b; color: #fff;
button.danger { border-color: #c0392b; color: #e74c3c; }
button.danger:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
/* инлайн-выбор хранилища в строке — компактный, не раздувает высоту строки (как тумблеры) */
table.cams td select.stor-sel {
/* инлайн-селекты в строке (хранилище, канал) — компактные, не раздувают высоту строки */
table.cams td select.stor-sel, table.cams td select.cell-sel {
height: 22px; padding: 1px 4px; font-size: 12px; line-height: 1;
border-radius: 5px; width: 100%; max-width: 150px; box-sizing: border-box;
vertical-align: middle;