337 lines
14 KiB
HTML
337 lines
14 KiB
HTML
{{define "layout"}}
|
|
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>{{.Title}}</title>
|
|
<link rel="stylesheet" href="/ui/assets/vendor/tabler.min.css" />
|
|
<link rel="stylesheet" href="/ui/assets/style.css?v=20260429-ia02" />
|
|
<link rel="stylesheet" href="/ui/assets/graph_editor.css?v=20260429-ia01" />
|
|
</head>
|
|
<body data-theme="blue-dark">
|
|
<div class="app-shell">
|
|
<aside class="sidebar">
|
|
<div class="brand-block">
|
|
<div class="brand-mark">AI</div>
|
|
<div>
|
|
<div class="brand-title">视觉识别运维平台</div>
|
|
</div>
|
|
</div>
|
|
<nav class="side-nav" aria-label="主导航">
|
|
<div class="nav-section">主模块</div>
|
|
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
|
|
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
|
|
<a href="/ui/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景</span></a>
|
|
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>视频通道</span></a>
|
|
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>通道部署</span></a>
|
|
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>配置中心</span></a>
|
|
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务中心</span></a>
|
|
<details class="nav-group" id="system-nav-group">
|
|
<summary>
|
|
<span class="nav-icon">{{icon "system"}}</span>
|
|
<span>系统管理</span>
|
|
</summary>
|
|
<div class="nav-group-items">
|
|
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
|
|
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
|
|
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
|
|
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
|
|
</div>
|
|
</details>
|
|
</nav>
|
|
</aside>
|
|
<div class="workspace">
|
|
<header class="topbar">
|
|
<div class="topbar-title">
|
|
<h1>{{.Title}}</h1>
|
|
</div>
|
|
<div class="topbar-actions">
|
|
<div class="theme-menu">
|
|
<button type="button" class="topbar-icon-btn js-theme-menu-toggle" aria-label="主题" title="主题">
|
|
{{icon "theme"}}
|
|
</button>
|
|
<div class="theme-menu-panel" hidden>
|
|
<button type="button" data-theme-option="blue-dark">黑白暗色</button>
|
|
<button type="button" data-theme-option="blue-light">蓝灰浅色</button>
|
|
<button type="button" data-theme-option="graphite-gold">石墨金色</button>
|
|
</div>
|
|
</div>
|
|
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="日志审计" title="日志审计">
|
|
{{icon "bell"}}
|
|
<span class="topbar-dot" aria-hidden="true"></span>
|
|
</a>
|
|
<a class="topbar-icon-btn" href="/ui/system" aria-label="系统" title="系统">
|
|
{{icon "system"}}
|
|
</a>
|
|
</div>
|
|
</header>
|
|
<main>
|
|
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
|
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
|
|
{{.ContentHTML}}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
<script src="/ui/assets/vendor/tabler.min.js"></script>
|
|
<script>
|
|
(function () {
|
|
const themeKey = "3588-admin-theme";
|
|
const allowedThemes = new Set(["blue-dark", "blue-light", "graphite-gold"]);
|
|
const themeLabels = {
|
|
"blue-dark": "黑白暗色",
|
|
"blue-light": "蓝灰浅色",
|
|
"graphite-gold": "石墨金色"
|
|
};
|
|
|
|
function applyTheme(theme, persist) {
|
|
const nextTheme = allowedThemes.has(theme) ? theme : "blue-dark";
|
|
document.body.setAttribute("data-theme", nextTheme);
|
|
const themeToggle = document.querySelector(".js-theme-menu-toggle");
|
|
if (themeToggle) {
|
|
const label = "当前主题:" + themeLabels[nextTheme];
|
|
themeToggle.setAttribute("aria-label", label);
|
|
themeToggle.setAttribute("title", label);
|
|
}
|
|
if (persist) {
|
|
localStorage.setItem(themeKey, nextTheme);
|
|
}
|
|
document.querySelectorAll("[data-theme-option]").forEach(function (button) {
|
|
const active = button.getAttribute("data-theme-option") === nextTheme;
|
|
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
applyTheme(localStorage.getItem("3588-admin-theme") || document.body.getAttribute("data-theme"), false);
|
|
|
|
document.querySelectorAll("[data-theme-option]").forEach(function (themeOption) {
|
|
themeOption.addEventListener("click", function (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
applyTheme(themeOption.getAttribute("data-theme-option"), true);
|
|
const panel = themeOption.closest(".theme-menu-panel");
|
|
if (panel) {
|
|
panel.hidden = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
function openThemePanel(themeToggle, panel) {
|
|
if (panel.parentElement !== document.body) {
|
|
document.body.appendChild(panel);
|
|
}
|
|
const rect = themeToggle.getBoundingClientRect();
|
|
panel.hidden = false;
|
|
const panelWidth = panel.offsetWidth || 132;
|
|
const viewportPadding = 12;
|
|
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
|
|
const preferredLeft = rect.right - panelWidth;
|
|
const left = Math.min(Math.max(viewportPadding, preferredLeft), maxLeft);
|
|
panel.style.top = Math.round(rect.bottom + 6) + "px";
|
|
panel.style.left = Math.round(left) + "px";
|
|
panel.style.right = "auto";
|
|
}
|
|
|
|
function suggestedFilenameFromHeader(headerValue, fallback) {
|
|
if (!headerValue) return fallback;
|
|
const match = /filename="?([^"]+)"?/i.exec(headerValue);
|
|
if (!match || !match[1]) return fallback;
|
|
return match[1];
|
|
}
|
|
|
|
async function saveBlobFromURL(url, defaultFilename, types, errorMessage) {
|
|
if (window.showSaveFilePicker) {
|
|
const response = await fetch(url, { credentials: "same-origin" });
|
|
if (!response.ok) {
|
|
throw new Error(errorMessage);
|
|
}
|
|
const blob = await response.blob();
|
|
const filename = suggestedFilenameFromHeader(response.headers.get("Content-Disposition"), defaultFilename);
|
|
const handle = await window.showSaveFilePicker({
|
|
suggestedName: filename,
|
|
types: types
|
|
});
|
|
const writable = await handle.createWritable();
|
|
await writable.write(blob);
|
|
await writable.close();
|
|
return;
|
|
}
|
|
throw new Error("当前浏览器不支持选择保存目录和文件名,请使用支持文件保存对话框的浏览器。");
|
|
}
|
|
|
|
async function saveJSONFromURL(url, defaultFilename) {
|
|
return saveBlobFromURL(
|
|
url,
|
|
defaultFilename || "config.json",
|
|
[{
|
|
description: "JSON 文件",
|
|
accept: { "application/json": [".json"] }
|
|
}],
|
|
"导出失败"
|
|
);
|
|
}
|
|
|
|
async function saveDBFromURL(url, defaultFilename) {
|
|
return saveBlobFromURL(
|
|
url,
|
|
defaultFilename || "app.db",
|
|
[{
|
|
description: "SQLite 数据库",
|
|
accept: { "application/octet-stream": [".db"], "application/x-sqlite3": [".db"] }
|
|
}],
|
|
"备份失败"
|
|
);
|
|
}
|
|
|
|
document.addEventListener("click", function (event) {
|
|
const themeToggle = event.target.closest(".js-theme-menu-toggle");
|
|
if (themeToggle) {
|
|
event.preventDefault();
|
|
const panel = document.querySelector(".theme-menu-panel");
|
|
if (panel) {
|
|
if (panel.hidden) {
|
|
openThemePanel(themeToggle, panel);
|
|
} else {
|
|
panel.hidden = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const btn = event.target.closest(".js-export-json");
|
|
if (btn) {
|
|
event.preventDefault();
|
|
const url = btn.getAttribute("data-export-url");
|
|
const filename = btn.getAttribute("data-default-filename");
|
|
saveJSONFromURL(url, filename).catch(function (err) {
|
|
window.alert(err && err.message ? err.message : "导出失败");
|
|
});
|
|
return;
|
|
}
|
|
const dbBtn = event.target.closest(".js-export-db");
|
|
if (dbBtn) {
|
|
event.preventDefault();
|
|
const url = dbBtn.getAttribute("data-export-url");
|
|
const filename = dbBtn.getAttribute("data-default-filename");
|
|
saveDBFromURL(url, filename).catch(function (err) {
|
|
window.alert(err && err.message ? err.message : "备份失败");
|
|
});
|
|
return;
|
|
}
|
|
|
|
const editToggle = event.target.closest(".js-inline-edit-toggle");
|
|
if (editToggle) {
|
|
event.preventDefault();
|
|
const targetId = editToggle.getAttribute("data-target");
|
|
const form = targetId ? document.getElementById(targetId) : null;
|
|
const display = editToggle.closest(".editable-line");
|
|
if (form) {
|
|
form.hidden = false;
|
|
if (display) display.hidden = true;
|
|
const input = form.querySelector("input:not([type=hidden])");
|
|
if (input) {
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const instanceRow = event.target.closest("[data-instance-row]");
|
|
if (instanceRow && !event.target.closest("button, a, input, select, textarea")) {
|
|
const index = instanceRow.getAttribute("data-instance-row");
|
|
const activeInput = document.getElementById("active-instance-input");
|
|
if (activeInput && index !== null) {
|
|
activeInput.value = index;
|
|
}
|
|
document.querySelectorAll("[data-instance-editor]").forEach(function (panel) {
|
|
panel.hidden = panel.getAttribute("data-instance-editor") !== index;
|
|
});
|
|
document.querySelectorAll("[data-instance-row]").forEach(function (row) {
|
|
row.classList.toggle("selected", row.getAttribute("data-instance-row") === index);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const profileRow = event.target.closest("[data-profile-row]");
|
|
if (profileRow && !event.target.closest("button, a, input, select, textarea")) {
|
|
const href = profileRow.getAttribute("data-profile-href");
|
|
if (href) {
|
|
window.location.href = href;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const navRow = event.target.closest("[data-nav-row]");
|
|
if (navRow && !event.target.closest("button, a, input, select, textarea")) {
|
|
const href = navRow.getAttribute("data-nav-href");
|
|
if (href) {
|
|
window.location.href = href;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const instanceEditorToggle = event.target.closest(".js-open-instance-editor");
|
|
if (instanceEditorToggle) {
|
|
event.preventDefault();
|
|
const index = instanceEditorToggle.getAttribute("data-instance-index");
|
|
const activeInput = document.getElementById("active-instance-input");
|
|
if (activeInput && index !== null) {
|
|
activeInput.value = index;
|
|
}
|
|
document.querySelectorAll("[data-instance-editor]").forEach(function (panel) {
|
|
panel.hidden = panel.getAttribute("data-instance-editor") !== index;
|
|
});
|
|
document.querySelectorAll("[data-instance-row]").forEach(function (row) {
|
|
row.classList.toggle("selected", row.getAttribute("data-instance-row") === index);
|
|
});
|
|
const targetId = instanceEditorToggle.getAttribute("data-target");
|
|
const panel = targetId ? document.getElementById(targetId) : null;
|
|
if (panel) {
|
|
panel.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
const input = panel.querySelector("input:not([type=hidden]):not([readonly]), textarea, select");
|
|
if (input) {
|
|
input.focus();
|
|
if (input.tagName === "INPUT" || input.tagName === "TEXTAREA") {
|
|
input.select();
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const editCancel = event.target.closest(".js-inline-edit-cancel");
|
|
if (editCancel) {
|
|
event.preventDefault();
|
|
const targetId = editCancel.getAttribute("data-target");
|
|
const form = targetId ? document.getElementById(targetId) : null;
|
|
if (form) {
|
|
form.hidden = true;
|
|
const container = form.parentElement;
|
|
const display = container ? container.querySelector(".editable-line") : null;
|
|
if (display) display.hidden = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
const systemNavGroup = document.getElementById("system-nav-group");
|
|
if (systemNavGroup) {
|
|
const path = window.location.pathname || "";
|
|
if (
|
|
path === "/ui/models" ||
|
|
path === "/ui/resources" ||
|
|
path === "/ui/diagnostics" ||
|
|
path === "/ui/system" ||
|
|
path === "/ui/audit" ||
|
|
path === "/ui/api"
|
|
) {
|
|
systemNavGroup.open = true;
|
|
}
|
|
}
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|