3588AdminBackend/internal/web/ui/templates/config_friendly.html

546 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "config_friendly"}}
{{template "device_nav" .}}
<div class="card">
<h2>识别方案配置</h2>
<div class="muted small">节点:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
</div>
<div class="card config-wizard">
<div class="wizard-steps">
<button type="button" class="wizard-step active" data-step="node">1 选择节点</button>
<button type="button" class="wizard-step" data-step="channels">2 编辑视频通道</button>
<button type="button" class="wizard-step" data-step="preview">3 预览变更</button>
<button type="button" class="wizard-step" data-step="deploy">4 部署结果</button>
</div>
<div class="wizard-panel" data-panel="node">
<h2>选择节点</h2>
<div class="drawer-grid" style="margin-top:12px">
<div><div class="muted small">节点名称</div><div>{{.Device.DeviceName}}</div></div>
<div><div class="muted small">节点标识</div><div class="mono">{{.Device.DeviceID}}</div></div>
<div><div class="muted small">管理地址</div><div class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</div></div>
<div><div class="muted small">状态</div><div>{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</div></div>
</div>
<div class="actions" style="margin-top:12px"><button type="button" onclick="activateWizardStep('channels')">下一步:编辑视频通道</button></div>
</div>
<div class="wizard-panel" data-panel="channels">
<h2>编辑视频通道</h2>
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('cfg-editor').scrollIntoView({block:'start'}); activateWizardStep('channels')">前往通道编辑器</button></div>
</div>
<div class="wizard-panel" data-panel="preview">
<h2>预览变更</h2>
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('btn-plan').click()">前往预览变更</button></div>
</div>
<div class="wizard-panel" data-panel="deploy">
<h2>部署结果</h2>
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('cfg-result').scrollIntoView({block:'start'})">查看部署结果</button></div>
</div>
</div>
<div class="card" id="cfg-status">
<h2 id="cfg-status-title">加载中</h2>
<div class="muted small" id="cfg-status-msg">正在读取配置结构和当前状态...</div>
<div class="actions" id="cfg-help" style="display:none;margin-top:10px">
<a href="/ui/devices">去设备列表重新扫描</a>
<a href="/ui/devices/{{.Device.DeviceID}}/config-ui">打开高级 JSON</a>
</div>
</div>
<div class="card" id="cfg-editor" data-wizard-section="channels" style="display:none">
<h2>视频通道</h2>
<div class="row" style="margin-top:10px; gap:16px">
<div style="flex:1">
<div class="muted small">新增或编辑视频通道</div>
<div class="row" style="margin-top:8px">
<div>
<div class="muted small">通道名称</div>
<input id="inst-name" placeholder="cam1" />
</div>
<div>
<div class="muted small">识别模板</div>
<select id="inst-template"></select>
</div>
</div>
<div style="margin-top:10px" id="inst-fields"></div>
<div class="actions" style="margin-top:10px">
<button id="btn-save">保存到列表</button>
<button id="btn-reset" type="button">清空表单</button>
</div>
<div class="muted small" style="margin-top:8px">提示:这里只编辑视频通道列表,系统级参数会沿用设备当前配置。</div>
</div>
<div style="flex:1">
<div class="muted small">当前视频通道(点击编辑)</div>
<div class="table-wrap" style="margin-top:8px">
<table id="inst-table" style="min-width:640px">
<thead>
<tr><th>通道</th><th>识别模板</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="actions" style="margin-top:10px">
<button id="btn-plan" type="button">预览变更</button>
<button id="btn-apply" type="button">部署到设备</button>
</div>
</div>
</div>
</div>
<div class="card" id="cfg-result" data-wizard-section="deploy" style="display:none">
<h2>执行结果</h2>
<pre id="result-pre"></pre>
</div>
<div class="card" id="cfg-json" data-wizard-section="preview" style="display:none">
<h2>高级 JSON 预览</h2>
<pre id="payload-pre"></pre>
</div>
<script>
(() => {
const deviceId = {{printf "%q" .Device.DeviceID}};
const statusEl = document.getElementById('cfg-status');
const statusTitleEl = document.getElementById('cfg-status-title');
const statusMsgEl = document.getElementById('cfg-status-msg');
const helpEl = document.getElementById('cfg-help');
const editorEl = document.getElementById('cfg-editor');
const resultEl = document.getElementById('cfg-result');
const resultPre = document.getElementById('result-pre');
const payloadEl = document.getElementById('cfg-json');
const payloadPre = document.getElementById('payload-pre');
const nameEl = document.getElementById('inst-name');
const tplEl = document.getElementById('inst-template');
const fieldsEl = document.getElementById('inst-fields');
const tableBody = document.querySelector('#inst-table tbody');
const btnSave = document.getElementById('btn-save');
const btnReset = document.getElementById('btn-reset');
const btnPlan = document.getElementById('btn-plan');
const btnApply = document.getElementById('btn-apply');
const wizardSteps = Array.from(document.querySelectorAll('.wizard-step'));
const wizardPanels = Array.from(document.querySelectorAll('.wizard-panel'));
function activateWizardStep(step){
for(const el of wizardSteps) el.classList.toggle('active', el.getAttribute('data-step') === step);
for(const panel of wizardPanels) panel.classList.toggle('active', panel.getAttribute('data-panel') === step);
}
for(const el of wizardSteps){
el.addEventListener('click', () => activateWizardStep(el.getAttribute('data-step')));
}
activateWizardStep('node');
const idFromPath = (() => {
try{
const parts = String(location.pathname || '').split('/');
// /ui/devices/{id}/config-friendly
if(parts.length > 3 && parts[1] === 'ui' && parts[2] === 'devices') return decodeURIComponent(parts[3] || '');
}catch(e){}
return '';
})();
const effectiveId = idFromPath || deviceId;
const apiBase = `/api/devices/${encodeURIComponent(effectiveId)}/v1`;
const PRD_FALLBACK = {
transcode_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
yolo_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'model_path', t:'string', req:true},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
yolo_alarm_minio: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'model_path', t:'string', req:true},
{k:'minio_endpoint', t:'string', req:true},
{k:'minio_bucket', t:'string', req:true},
{k:'minio_ak', t:'string', req:true},
{k:'minio_sk', t:'string', req:true},
{k:'cooldown_ms', t:'integer', def:3000}
]
},
face_det_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'det_model_path', t:'string', req:true},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
face_det_recog_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'det_model_path', t:'string', req:true},
{k:'recog_model_path', t:'string', req:true},
{k:'gallery_path', t:'string', def:'./models/face_gallery.db'},
{k:'thr_accept', t:'number', def:0.45},
{k:'thr_margin', t:'number', def:0.05},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
}
};
let baseState = { global: undefined, queue: undefined, instances: [] };
let templateSchemas = {}; // name -> {properties, required}
const esc = (s) => String(s ?? '');
const pretty = (obj) => {
try { return JSON.stringify(obj, null, 2); } catch(e) { return String(obj); }
};
function setStatus(title, msg, showHelp){
statusEl.style.display = '';
statusTitleEl.textContent = esc(title);
statusMsgEl.textContent = esc(msg);
helpEl.style.display = showHelp ? '' : 'none';
}
function normalizeSchema(schema){
// Expect something like: {templates:[{name, schema:{type:'object', properties:{...}, required:[...]}}]}
// Fallback: {templates:["..."]}
const out = { templates: [], schemas: {} };
if(!schema || typeof schema !== 'object') return out;
const tpls = schema.templates;
if(Array.isArray(tpls)){
for(const t of tpls){
if(typeof t === 'string'){
out.templates.push(t);
continue;
}
if(t && typeof t === 'object'){
const name = t.name || t.template || t.id;
if(typeof name === 'string' && name){
out.templates.push(name);
const sch = t.schema || t.params_schema || t.paramsSchema;
if(sch && typeof sch === 'object') out.schemas[name] = sch;
}
}
}
}
// Sometimes schema might be a map: {template_schemas:{...}}
const m = schema.template_schemas || schema.templateSchemas || schema.schemas;
if(m && typeof m === 'object'){
for(const [k,v] of Object.entries(m)){
if(!out.templates.includes(k)) out.templates.push(k);
if(v && typeof v === 'object') out.schemas[k] = v;
}
}
if(!out.templates.length){
out.templates = Object.keys(PRD_FALLBACK);
}
return out;
}
function buildFieldList(tplName){
const sch = templateSchemas[tplName];
if(sch && sch.type === 'object' && sch.properties && typeof sch.properties === 'object'){
const req = Array.isArray(sch.required) ? new Set(sch.required) : new Set();
const fields = [];
for(const [k,prop] of Object.entries(sch.properties)){
const t = (prop && typeof prop.type === 'string') ? prop.type : 'string';
fields.push({k, t, req: req.has(k), def: prop.default, enum: prop.enum, desc: prop.description || prop.title});
}
return fields;
}
const fb = PRD_FALLBACK[tplName];
return fb ? fb.fields : [];
}
function inputForField(f, value){
const id = `f_${f.k}`;
const label = f.label || f.k;
const req = f.req ? '<span class="muted small">(必填)</span>' : '';
const desc = f.desc ? `<div class="muted small" style="margin-top:6px">${esc(f.desc)}</div>` : '';
if(Array.isArray(f.enum) && f.enum.length){
const opts = f.enum.map(v => `<option value=${JSON.stringify(String(v))}>${esc(v)}</option>`).join('');
return `<div class="card" style="margin:10px 0;padding:10px 12px">
<div class="muted small">${esc(label)} ${req}</div>
<select id="${id}">${opts}</select>
${desc}
</div>`;
}
const type = (f.t === 'integer' || f.t === 'number') ? 'number' : 'text';
const step = (f.t === 'number') ? 'any' : '1';
const v = (value !== undefined && value !== null) ? value : (f.def !== undefined ? f.def : '');
return `<div class="card" style="margin:10px 0;padding:10px 12px">
<div class="muted small">${esc(label)} ${req}</div>
<input id="${id}" type="${type}" step="${step}" value=${JSON.stringify(String(v))} />
${desc}
</div>`;
}
function collectParams(tplName){
const fields = buildFieldList(tplName);
const params = {};
for(const f of fields){
const el = document.getElementById(`f_${f.k}`);
if(!el) continue;
let v = el.value;
if(v === '' || v === null || v === undefined){
if(f.req) throw new Error(`缺少必填字段: ${f.k}`);
continue;
}
if(f.t === 'integer'){
const n = Number(v);
if(!Number.isFinite(n)) throw new Error(`字段 ${f.k} 不是有效整数`);
params[f.k] = Math.trunc(n);
}else if(f.t === 'number'){
const n = Number(v);
if(!Number.isFinite(n)) throw new Error(`字段 ${f.k} 不是有效数字`);
params[f.k] = n;
}else{
params[f.k] = String(v);
}
}
return params;
}
function renderFields(tplName, params){
const fields = buildFieldList(tplName);
if(!fields.length){
fieldsEl.innerHTML = '<div class="muted small">(该模板没有可渲染字段;如需支持,请补充配置结构或使用高级 JSON。</div>';
return;
}
fieldsEl.innerHTML = fields.map(f => inputForField(f, params ? params[f.k] : undefined)).join('');
// Auto fill hls_path if present
const hls = document.getElementById('f_hls_path');
if(hls && !hls.value){
const nm = (nameEl.value || '').trim();
if(nm) hls.value = `./web/hls/${nm}/index.m3u8`;
}
}
function renderTable(){
tableBody.innerHTML = '';
for(const inst of baseState.instances){
const tr = document.createElement('tr');
tr.innerHTML = `<td class="mono">${esc(inst.name)}</td><td class="mono">${esc(inst.template)}</td><td></td>`;
const td = tr.querySelector('td:last-child');
const btnEdit = document.createElement('button');
btnEdit.textContent = '编辑';
btnEdit.type = 'button';
btnEdit.addEventListener('click', () => {
nameEl.value = inst.name || '';
tplEl.value = inst.template || '';
renderFields(tplEl.value, inst.params || {});
});
const btnDel = document.createElement('button');
btnDel.textContent = '删除';
btnDel.type = 'button';
btnDel.addEventListener('click', () => {
if(!confirm(`确认删除通道 ${inst.name} ?`)) return;
baseState.instances = baseState.instances.filter(x => x.name !== inst.name);
renderTable();
renderPayload();
});
td.appendChild(btnEdit);
td.appendChild(document.createTextNode(' '));
td.appendChild(btnDel);
tableBody.appendChild(tr);
}
}
function buildPayload(){
const p = { instances: baseState.instances };
if(baseState.global !== undefined) p.global = baseState.global;
if(baseState.queue !== undefined) p.queue = baseState.queue;
return p;
}
function renderPayload(){
payloadEl.style.display = '';
payloadPre.textContent = pretty(buildPayload());
}
async function postJson(path, obj){
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(obj)
});
const txt = await res.text();
let j;
try { j = JSON.parse(txt); } catch(e) {}
if(!res.ok){
const msg = j && j.error ? j.error : txt;
throw new Error(`HTTP ${res.status}: ${msg}`);
}
return j || txt;
}
async function load(){
setStatus('加载中', '正在读取配置结构和当前状态...');
let schema, state;
try{
const schemaUrl = `${apiBase}/config/ui/schema`;
const stateUrl = `${apiBase}/config/ui/state`;
const [s1, s2] = await Promise.all([fetch(schemaUrl), fetch(stateUrl)]);
const t1 = await s1.text();
const t2 = await s2.text();
schema = (() => { try { return JSON.parse(t1); } catch(e) { return null; } })();
state = (() => { try { return JSON.parse(t2); } catch(e) { return null; } })();
if(!s1.ok){
const extra = `\n\n请求GET ${schemaUrl}\n节点标识(页面): ${deviceId}\n节点标识(URL): ${idFromPath || '-'}`;
const hint = (String(t1).includes('device not found') || String(t1).includes('DEVICE_NOT_FOUND'))
? `\n\n提示:设备列表保存在当前服务内存里;如果刚重启服务,需要先到“设备列表”页点一次“开始扫描”。`
: '';
throw new Error(`配置结构 HTTP ${s1.status}: ${t1}${extra}${hint}`);
}
if(!s2.ok){
const extra = `\n\n请求GET ${stateUrl}\n节点标识(页面): ${deviceId}\n节点标识(URL): ${idFromPath || '-'}`;
throw new Error(`当前状态 HTTP ${s2.status}: ${t2}${extra}`);
}
}catch(e){
setStatus('加载失败', String(e), true);
return;
}
if(schema && state){
const norm = normalizeSchema(schema);
templateSchemas = norm.schemas || {};
tplEl.innerHTML = '';
for(const n of norm.templates){
const opt = document.createElement('option');
opt.value = n;
opt.textContent = n;
tplEl.appendChild(opt);
}
baseState = {
global: state && typeof state.global === 'object' ? state.global : undefined,
queue: state && typeof state.queue === 'object' ? state.queue : undefined,
instances: Array.isArray(state && state.instances) ? state.instances.map(x => ({
name: x.name,
template: x.template,
params: (x.params && typeof x.params === 'object') ? x.params : {}
})) : []
};
statusEl.style.display = 'none';
editorEl.style.display = '';
renderTable();
tplEl.value = norm.templates[0] || '';
renderFields(tplEl.value, {});
renderPayload();
return;
}
}
tplEl.addEventListener('change', () => renderFields(tplEl.value, {}));
nameEl.addEventListener('input', () => {
const hls = document.getElementById('f_hls_path');
if(hls && (!hls.value || hls.value.includes('/hls/'))) {
const nm = (nameEl.value || '').trim();
if(nm) hls.value = `./web/hls/${nm}/index.m3u8`;
}
});
btnReset.addEventListener('click', () => {
nameEl.value = '';
renderFields(tplEl.value, {});
});
btnSave.addEventListener('click', (ev) => {
ev.preventDefault();
resultEl.style.display = 'none';
try{
const name = (nameEl.value || '').trim();
if(!name) throw new Error('name 不能为空');
const tplName = tplEl.value;
if(!tplName) throw new Error('template 不能为空');
const params = collectParams(tplName);
const next = { name, template: tplName, params };
const idx = baseState.instances.findIndex(x => x.name === name);
if(idx >= 0) baseState.instances[idx] = next;
else baseState.instances.push(next);
baseState.instances.sort((a,b) => String(a.name).localeCompare(String(b.name)));
renderTable();
renderPayload();
setStatus('已更新', `已保存到列表:${name}`);
statusEl.style.display = '';
setTimeout(() => { statusEl.style.display = 'none'; }, 1200);
}catch(e){
setStatus('校验失败', String(e));
statusEl.style.display = '';
}
});
btnPlan.addEventListener('click', async () => {
resultEl.style.display = 'none';
activateWizardStep('preview');
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/plan`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){
resultEl.style.display = '';
resultPre.textContent = String(e);
}
});
btnApply.addEventListener('click', async () => {
resultEl.style.display = 'none';
if(!confirm('确认部署到设备?系统会写入配置并重载视频分析服务,失败时会尝试回滚。')) return;
activateWizardStep('deploy');
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/apply`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){
resultEl.style.display = '';
resultPre.textContent = String(e);
}
});
load();
})();
</script>
{{end}}