lint / go (push) Canceled after 0s
lint / go_mod (push) Canceled after 0s
lint / conf (push) Canceled after 0s
lint / docslinks (push) Canceled after 0s
lint / docsorder (push) Canceled after 0s
lint / apidocs (push) Canceled after 0s
lint / other (push) Canceled after 0s
test / test_64 (push) Canceled after 0s
test / test_32 (push) Canceled after 0s
test / test_e2e (push) Canceled after 0s
890 lines
31 KiB
JavaScript
890 lines
31 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const ListTools = window.MediaMTXAdminList;
|
||
const API_PAGE_SIZE = 100;
|
||
const API_PAGE_CONCURRENCY = 4;
|
||
|
||
const API = {
|
||
info: "/v3/info",
|
||
configs: "/v3/config/paths/list",
|
||
paths: "/v3/paths/list",
|
||
global: "/v3/config/global/get",
|
||
};
|
||
|
||
const AUTH_STORAGE_KEY = "mediamtx.admin.authorization";
|
||
|
||
const state = {
|
||
configs: [],
|
||
runtime: new Map(),
|
||
global: {},
|
||
editingName: null,
|
||
deletingName: null,
|
||
previewName: null,
|
||
previewProtocol: "webrtc",
|
||
loading: false,
|
||
toastTimer: null,
|
||
authorization: "",
|
||
currentPage: 1,
|
||
pageSize: 50,
|
||
};
|
||
|
||
const elements = {};
|
||
|
||
class APIError extends Error {
|
||
constructor(status, message) {
|
||
super(message);
|
||
this.name = "APIError";
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
function byID(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function create(tag, options = {}) {
|
||
const node = document.createElement(tag);
|
||
if (options.className) node.className = options.className;
|
||
if (options.text !== undefined) node.textContent = String(options.text);
|
||
if (options.title) node.title = options.title;
|
||
return node;
|
||
}
|
||
|
||
function redactSensitiveText(value) {
|
||
return String(value || "").replace(
|
||
/(rtsps?:\/\/)([^\s/:@]+):([^\s/@]+)@/gi,
|
||
"$1$2:••••@",
|
||
);
|
||
}
|
||
|
||
function readStoredAuthorization() {
|
||
try {
|
||
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) || "";
|
||
} catch (_) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function saveAuthorization(value) {
|
||
state.authorization = value;
|
||
try {
|
||
window.sessionStorage.setItem(AUTH_STORAGE_KEY, value);
|
||
} catch (_) {
|
||
// The current tab can still stay authenticated when storage is unavailable.
|
||
}
|
||
}
|
||
|
||
function clearAuthorization() {
|
||
state.authorization = "";
|
||
try {
|
||
window.sessionStorage.removeItem(AUTH_STORAGE_KEY);
|
||
} catch (_) {
|
||
// Nothing else is required when storage is unavailable.
|
||
}
|
||
}
|
||
|
||
function basicAuthorization(username, password) {
|
||
const bytes = new TextEncoder().encode(`${username}:${password}`);
|
||
let binary = "";
|
||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||
return `Basic ${window.btoa(binary)}`;
|
||
}
|
||
|
||
async function apiFetch(url, options = {}) {
|
||
const headers = new Headers(options.headers || {});
|
||
if (options.body !== undefined) headers.set("Content-Type", "application/json");
|
||
const authorization = Object.prototype.hasOwnProperty.call(options, "authorization")
|
||
? options.authorization
|
||
: state.authorization;
|
||
if (authorization) headers.set("Authorization", authorization);
|
||
|
||
const requestOptions = { ...options };
|
||
const handleUnauthorized = requestOptions.handleUnauthorized !== false;
|
||
delete requestOptions.authorization;
|
||
delete requestOptions.handleUnauthorized;
|
||
|
||
let response;
|
||
try {
|
||
response = await fetch(url, {
|
||
...requestOptions,
|
||
headers,
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
});
|
||
} catch (error) {
|
||
throw new APIError(0, "无法连接 MediaMTX Control API。请检查服务和网络连接。");
|
||
}
|
||
|
||
if (!response.ok) {
|
||
let detail = "";
|
||
try {
|
||
const payload = await response.json();
|
||
detail = typeof payload.error === "string" ? redactSensitiveText(payload.error) : "";
|
||
} catch (_) {
|
||
detail = "";
|
||
}
|
||
|
||
if (response.status === 401) {
|
||
if (handleUnauthorized && state.authorization) {
|
||
endSession("登录已失效,请重新登录。");
|
||
}
|
||
throw new APIError(401, "认证失败。请使用具有 action: api 权限的凭据重新访问此页面。");
|
||
}
|
||
if (response.status === 403) {
|
||
throw new APIError(403, "当前凭据没有修改路径的 action: api 权限。");
|
||
}
|
||
|
||
throw new APIError(response.status, detail || `请求失败(HTTP ${response.status})。`);
|
||
}
|
||
|
||
if (response.status === 204) return null;
|
||
const contentType = response.headers.get("Content-Type") || "";
|
||
return contentType.includes("json") ? response.json() : null;
|
||
}
|
||
|
||
function maskSource(raw) {
|
||
try {
|
||
const parsed = new URL(raw);
|
||
const username = parsed.username ? decodeURIComponent(parsed.username) : "";
|
||
const auth = username
|
||
? `${username}${parsed.password ? ":••••" : ""}@`
|
||
: "";
|
||
return `${parsed.protocol}//${auth}${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||
} catch (_) {
|
||
return "RTSP 地址不可解析";
|
||
}
|
||
}
|
||
|
||
function scrubConfig(item) {
|
||
return {
|
||
name: String(item.name || ""),
|
||
sourceMasked: maskSource(String(item.source || "")),
|
||
transport: ["automatic", "tcp", "udp"].includes(item.rtspTransport)
|
||
? item.rtspTransport
|
||
: "automatic",
|
||
sourceOnDemand: Boolean(item.sourceOnDemand),
|
||
};
|
||
}
|
||
|
||
function isRTSPSource(item) {
|
||
const source = String(item.source || "").toLowerCase();
|
||
return source.startsWith("rtsp://") || source.startsWith("rtsps://");
|
||
}
|
||
|
||
function getPathState(config) {
|
||
const runtime = state.runtime.get(config.name);
|
||
if (runtime?.available) {
|
||
return { key: "online", label: "在线", className: "status-online" };
|
||
}
|
||
if (config.sourceOnDemand) {
|
||
return { key: "idle", label: "待请求", className: "status-idle" };
|
||
}
|
||
return { key: "offline", label: "离线", className: "status-offline" };
|
||
}
|
||
|
||
function readerCount(name) {
|
||
const readers = state.runtime.get(name)?.readers;
|
||
return Array.isArray(readers) ? readers.length : 0;
|
||
}
|
||
|
||
function setAPIStatus(kind, text) {
|
||
elements.apiStatus.className = `status-pill status-${kind}`;
|
||
elements.apiStatus.textContent = text;
|
||
}
|
||
|
||
function showLogin(message = "") {
|
||
elements.appView.hidden = true;
|
||
elements.loginView.hidden = false;
|
||
elements.loginError.textContent = message;
|
||
elements.loginUsername.removeAttribute("aria-invalid");
|
||
elements.loginPassword.removeAttribute("aria-invalid");
|
||
elements.loginPassword.value = "";
|
||
elements.loginPassword.type = "password";
|
||
elements.loginPasswordToggle.textContent = "显示";
|
||
elements.loginPasswordToggle.setAttribute("aria-pressed", "false");
|
||
elements.skipLink.href = "#login-main";
|
||
window.setTimeout(() => elements.loginUsername.focus(), 0);
|
||
}
|
||
|
||
function showApp() {
|
||
elements.loginView.hidden = true;
|
||
elements.appView.hidden = false;
|
||
elements.loginError.textContent = "";
|
||
elements.skipLink.href = "#main-content";
|
||
window.setTimeout(() => elements.mainContent.focus(), 0);
|
||
}
|
||
|
||
function endSession(message = "") {
|
||
clearAuthorization();
|
||
document.querySelectorAll("dialog[open]").forEach((dialog) => dialog.close());
|
||
elements.previewFrame.src = "about:blank";
|
||
showLogin(message);
|
||
}
|
||
|
||
function togglePassword(input, button) {
|
||
const showing = input.type === "text";
|
||
input.type = showing ? "password" : "text";
|
||
button.textContent = showing ? "显示" : "隐藏";
|
||
button.setAttribute("aria-pressed", String(!showing));
|
||
input.focus();
|
||
}
|
||
|
||
async function submitLogin(event) {
|
||
event.preventDefault();
|
||
const username = elements.loginUsername.value.trim();
|
||
const password = elements.loginPassword.value;
|
||
elements.loginError.textContent = "";
|
||
elements.loginUsername.removeAttribute("aria-invalid");
|
||
elements.loginPassword.removeAttribute("aria-invalid");
|
||
|
||
if (!username) {
|
||
elements.loginUsername.setAttribute("aria-invalid", "true");
|
||
elements.loginError.textContent = "请输入用户名。";
|
||
elements.loginUsername.focus();
|
||
return;
|
||
}
|
||
if (!password) {
|
||
elements.loginPassword.setAttribute("aria-invalid", "true");
|
||
elements.loginError.textContent = "请输入密码。";
|
||
elements.loginPassword.focus();
|
||
return;
|
||
}
|
||
|
||
setButtonBusy(elements.loginSubmit, true, "正在登录…", "登录");
|
||
try {
|
||
const authorization = basicAuthorization(username, password);
|
||
await apiFetch(API.info, { authorization, handleUnauthorized: false });
|
||
saveAuthorization(authorization);
|
||
showApp();
|
||
await loadData();
|
||
} catch (error) {
|
||
const apiError = error instanceof APIError ? error : new APIError(0, String(error));
|
||
elements.loginPassword.setAttribute("aria-invalid", "true");
|
||
elements.loginError.textContent = apiError.status === 401
|
||
? "用户名、密码或 API 权限不正确。"
|
||
: apiError.message;
|
||
elements.loginPassword.select();
|
||
} finally {
|
||
setButtonBusy(elements.loginSubmit, false, "", "登录");
|
||
}
|
||
}
|
||
|
||
async function restoreSession() {
|
||
const authorization = readStoredAuthorization();
|
||
if (!authorization) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
|
||
state.authorization = authorization;
|
||
setButtonBusy(elements.loginSubmit, true, "正在验证…", "登录");
|
||
try {
|
||
await apiFetch(API.info, { handleUnauthorized: false });
|
||
showApp();
|
||
await loadData();
|
||
} catch (error) {
|
||
clearAuthorization();
|
||
const apiError = error instanceof APIError ? error : new APIError(0, String(error));
|
||
showLogin(apiError.status === 401 ? "登录已失效,请重新登录。" : apiError.message);
|
||
} finally {
|
||
setButtonBusy(elements.loginSubmit, false, "", "登录");
|
||
}
|
||
}
|
||
|
||
function showPageError(error) {
|
||
elements.pageAlert.hidden = false;
|
||
elements.pageAlertTitle.textContent = error.status === 403 ? "没有操作权限" : "无法完整加载设备";
|
||
elements.pageAlertMessage.textContent = error.message;
|
||
setAPIStatus("error", error.status === 403 ? "API 权限不足" : "API 连接失败");
|
||
}
|
||
|
||
function hidePageError() {
|
||
elements.pageAlert.hidden = true;
|
||
elements.pageAlertTitle.textContent = "";
|
||
elements.pageAlertMessage.textContent = "";
|
||
}
|
||
|
||
function setLoading(loading) {
|
||
state.loading = loading;
|
||
elements.loadingState.hidden = !loading;
|
||
elements.refreshButton.disabled = loading;
|
||
if (loading) {
|
||
elements.pathsContent.hidden = true;
|
||
elements.emptyState.hidden = true;
|
||
elements.loadingText.textContent = "正在加载设备数据…";
|
||
setAPIStatus("loading", "正在连接 API");
|
||
}
|
||
}
|
||
|
||
function normalizeAPIError(error) {
|
||
if (error instanceof APIError) return error;
|
||
const status = Number.isInteger(error?.status) ? error.status : 0;
|
||
return new APIError(status, redactSensitiveText(error?.message || String(error)));
|
||
}
|
||
|
||
function loadingProgressReporter() {
|
||
const resources = new Map();
|
||
return (progress) => {
|
||
if (!state.loading) return;
|
||
resources.set(progress.label, progress);
|
||
let loaded = 0;
|
||
let total = 0;
|
||
for (const item of resources.values()) {
|
||
loaded += item.loaded;
|
||
total += item.total;
|
||
}
|
||
const message = total > 0
|
||
? `正在分批加载数据 ${Math.min(loaded, total)} / ${total}…`
|
||
: "正在加载设备数据…";
|
||
elements.loadingText.textContent = message;
|
||
setAPIStatus("loading", total > 0 ? `正在加载 ${Math.min(loaded, total)}/${total}` : "正在连接 API");
|
||
};
|
||
}
|
||
|
||
async function loadData() {
|
||
if (state.loading) return;
|
||
setLoading(true);
|
||
hidePageError();
|
||
|
||
try {
|
||
const reportProgress = loadingProgressReporter();
|
||
const pagedFetch = ListTools.limitConcurrency(apiFetch, API_PAGE_CONCURRENCY);
|
||
const [configData, pathData, globalData] = await Promise.all([
|
||
ListTools.fetchAllPages(pagedFetch, API.configs, {
|
||
label: "设备配置",
|
||
pageSize: API_PAGE_SIZE,
|
||
concurrency: API_PAGE_CONCURRENCY,
|
||
onProgress: reportProgress,
|
||
}),
|
||
ListTools.fetchAllPages(pagedFetch, API.paths, {
|
||
label: "运行状态",
|
||
pageSize: API_PAGE_SIZE,
|
||
concurrency: API_PAGE_CONCURRENCY,
|
||
onProgress: reportProgress,
|
||
}),
|
||
apiFetch(API.global).catch(() => ({})),
|
||
]);
|
||
|
||
const configs = ListTools.uniqueSortedByName(configData.items)
|
||
.filter(isRTSPSource)
|
||
.map(scrubConfig);
|
||
const runtimeItems = ListTools.uniqueSortedByName(pathData.items);
|
||
|
||
state.configs = configs;
|
||
state.runtime = new Map(runtimeItems.map((item) => [item.name, item]));
|
||
state.global = globalData || {};
|
||
|
||
setAPIStatus("ok", "API 已连接");
|
||
render();
|
||
} catch (error) {
|
||
const apiError = normalizeAPIError(error);
|
||
if (apiError.status === 401) return;
|
||
if (state.configs.length > 0) render();
|
||
else {
|
||
elements.pathsContent.hidden = true;
|
||
elements.emptyState.hidden = true;
|
||
updateStats([]);
|
||
}
|
||
showPageError(apiError);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function filteredConfigs() {
|
||
return ListTools.filterConfigs(
|
||
state.configs,
|
||
elements.searchInput.value,
|
||
elements.statusFilter.value,
|
||
(config) => getPathState(config).key,
|
||
);
|
||
}
|
||
|
||
function updateStats(configs) {
|
||
const counts = ListTools.countStatuses(configs, (config) => getPathState(config).key);
|
||
elements.statTotal.textContent = String(counts.total);
|
||
elements.statOnline.textContent = String(counts.online);
|
||
elements.statOffline.textContent = String(counts.offline);
|
||
elements.statIdle.textContent = String(counts.idle);
|
||
}
|
||
|
||
function statusNode(config) {
|
||
const status = getPathState(config);
|
||
return create("span", { className: `path-status ${status.className}`, text: status.label });
|
||
}
|
||
|
||
function actionButton(label, className, handler) {
|
||
const button = create("button", { className: `button ${className}`, text: label });
|
||
button.type = "button";
|
||
button.addEventListener("click", handler);
|
||
return button;
|
||
}
|
||
|
||
function actionsNode(config) {
|
||
const actions = create("div", { className: "row-actions" });
|
||
actions.append(
|
||
actionButton("预览", "button-primary", () => openPreview(config.name)),
|
||
actionButton("编辑", "button-secondary", () => openPathDialog(config)),
|
||
actionButton("删除", "button-secondary", () => openDeleteDialog(config.name)),
|
||
);
|
||
return actions;
|
||
}
|
||
|
||
function renderTable(configs) {
|
||
const rows = document.createDocumentFragment();
|
||
for (const config of configs) {
|
||
const row = create("tr");
|
||
const nameCell = create("td");
|
||
nameCell.append(create("span", { className: "path-name", text: config.name }));
|
||
const statusCell = create("td");
|
||
statusCell.append(statusNode(config));
|
||
const sourceCell = create("td");
|
||
sourceCell.append(create("span", {
|
||
className: "source-address",
|
||
text: config.sourceMasked,
|
||
title: config.sourceMasked,
|
||
}));
|
||
const transportCell = create("td", { text: transportLabel(config.transport) });
|
||
const readersCell = create("td", { className: "readers", text: readerCount(config.name) });
|
||
const actionsCell = create("td");
|
||
actionsCell.append(actionsNode(config));
|
||
row.append(nameCell, statusCell, sourceCell, transportCell, readersCell, actionsCell);
|
||
rows.append(row);
|
||
}
|
||
elements.tableBody.replaceChildren(rows);
|
||
}
|
||
|
||
function renderCards(configs) {
|
||
const cards = document.createDocumentFragment();
|
||
for (const config of configs) {
|
||
const card = create("article", { className: "path-card" });
|
||
const heading = create("div", { className: "path-card-heading" });
|
||
heading.append(create("strong", { className: "path-name", text: config.name }), statusNode(config));
|
||
card.append(
|
||
heading,
|
||
create("span", { className: "source-address", text: config.sourceMasked }),
|
||
create("p", {
|
||
className: "path-card-meta",
|
||
text: `${transportLabel(config.transport)} · ${readerCount(config.name)} 个读取者`,
|
||
}),
|
||
actionsNode(config),
|
||
);
|
||
cards.append(card);
|
||
}
|
||
elements.cardList.replaceChildren(cards);
|
||
}
|
||
|
||
function updatePagination(pageData) {
|
||
state.currentPage = pageData.page;
|
||
state.pageSize = pageData.pageSize;
|
||
elements.pageSize.value = String(pageData.pageSize);
|
||
elements.paginationSummary.textContent = `${pageData.start}–${pageData.end} / 共 ${pageData.totalItems} 条`;
|
||
elements.paginationPage.textContent = `${pageData.page} / ${pageData.totalPages} 页`;
|
||
elements.previousPage.disabled = pageData.page <= 1;
|
||
elements.nextPage.disabled = pageData.page >= pageData.totalPages;
|
||
}
|
||
|
||
function render() {
|
||
updateStats(state.configs);
|
||
const configs = filteredConfigs();
|
||
const hasFilters = Boolean(elements.searchInput.value.trim()) || elements.statusFilter.value !== "all";
|
||
|
||
if (configs.length === 0) {
|
||
elements.pathsContent.hidden = true;
|
||
elements.emptyState.hidden = false;
|
||
elements.emptyTitle.textContent = hasFilters ? "没有匹配的路径" : "还没有 RTSP 路径";
|
||
elements.emptyMessage.textContent = hasFilters
|
||
? "请调整搜索词或状态筛选。"
|
||
: "添加第一个摄像头或 RTSP 服务地址,之后可在列表中预览与管理。";
|
||
elements.emptyAddButton.hidden = hasFilters;
|
||
return;
|
||
}
|
||
|
||
const pageData = ListTools.paginateItems(configs, state.currentPage, state.pageSize);
|
||
elements.emptyState.hidden = true;
|
||
elements.pathsContent.hidden = false;
|
||
renderTable(pageData.items);
|
||
renderCards(pageData.items);
|
||
updatePagination(pageData);
|
||
}
|
||
|
||
function transportLabel(value) {
|
||
return { automatic: "自动", tcp: "TCP", udp: "UDP" }[value] || "自动";
|
||
}
|
||
|
||
function clearFieldError(input, errorElement) {
|
||
input.removeAttribute("aria-invalid");
|
||
errorElement.textContent = "";
|
||
}
|
||
|
||
function setFieldError(input, errorElement, message) {
|
||
input.setAttribute("aria-invalid", "true");
|
||
errorElement.textContent = message;
|
||
}
|
||
|
||
function validatePathName(name) {
|
||
if (!name) return "请输入路径名称。";
|
||
if (name === "all" || name === "all_others") return "该名称为 MediaMTX 保留名称。";
|
||
if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
|
||
return "路径名称不能以斜杠开头或结尾,也不能包含空路径段。";
|
||
}
|
||
if (name.split("/").some((part) => part === "." || part === "..")) {
|
||
return "路径名称不能包含 . 或 .. 路径段。";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function validateSource(value, required) {
|
||
if (!value) return required ? "请输入 RTSP 源地址。" : "";
|
||
try {
|
||
const parsed = new URL(value);
|
||
if (!["rtsp:", "rtsps:"].includes(parsed.protocol) || !parsed.hostname) {
|
||
return "请输入以 rtsp:// 或 rtsps:// 开头的有效地址。";
|
||
}
|
||
return "";
|
||
} catch (_) {
|
||
return "请输入有效的 RTSP 地址。";
|
||
}
|
||
}
|
||
|
||
function openPathDialog(config = null) {
|
||
state.editingName = config?.name || null;
|
||
const editing = Boolean(config);
|
||
elements.pathDialogTitle.textContent = editing ? "编辑 RTSP 源" : "添加 RTSP 源";
|
||
elements.pathSubmit.textContent = editing ? "保存修改" : "添加路径";
|
||
elements.pathName.value = config?.name || "";
|
||
elements.pathName.readOnly = editing;
|
||
elements.sourceURL.value = "";
|
||
elements.sourceURL.type = "password";
|
||
elements.sourceToggle.textContent = "显示";
|
||
elements.sourceToggle.setAttribute("aria-pressed", "false");
|
||
elements.transport.value = config?.transport || "automatic";
|
||
elements.sourceOnDemand.checked = Boolean(config?.sourceOnDemand);
|
||
elements.sourceRequired.hidden = editing;
|
||
elements.sourceHelp.textContent = editing
|
||
? `当前地址:${config.sourceMasked}。留空将保留原地址和凭据。`
|
||
: "例如 rtsp://user:password@camera.local/live。凭据不会持久化到浏览器。";
|
||
elements.formAlert.hidden = true;
|
||
clearFieldError(elements.pathName, elements.pathNameError);
|
||
clearFieldError(elements.sourceURL, elements.sourceError);
|
||
elements.pathDialog.showModal();
|
||
window.setTimeout(() => (editing ? elements.sourceURL : elements.pathName).focus(), 0);
|
||
}
|
||
|
||
function encodePathName(name) {
|
||
return name.split("/").map((part) => encodeURIComponent(part)).join("/");
|
||
}
|
||
|
||
function setButtonBusy(button, busy, busyText, normalText) {
|
||
button.disabled = busy;
|
||
button.textContent = busy ? busyText : normalText;
|
||
}
|
||
|
||
async function submitPath(event) {
|
||
event.preventDefault();
|
||
const editing = Boolean(state.editingName);
|
||
const name = elements.pathName.value.trim();
|
||
const source = elements.sourceURL.value.trim();
|
||
const nameError = validatePathName(name);
|
||
const sourceError = validateSource(source, !editing);
|
||
|
||
nameError
|
||
? setFieldError(elements.pathName, elements.pathNameError, nameError)
|
||
: clearFieldError(elements.pathName, elements.pathNameError);
|
||
sourceError
|
||
? setFieldError(elements.sourceURL, elements.sourceError, sourceError)
|
||
: clearFieldError(elements.sourceURL, elements.sourceError);
|
||
|
||
if (nameError || sourceError) {
|
||
(nameError ? elements.pathName : elements.sourceURL).focus();
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
rtspTransport: elements.transport.value,
|
||
sourceOnDemand: elements.sourceOnDemand.checked,
|
||
};
|
||
if (source) payload.source = source;
|
||
|
||
const normalText = editing ? "保存修改" : "添加路径";
|
||
setButtonBusy(elements.pathSubmit, true, "正在保存…", normalText);
|
||
elements.formAlert.hidden = true;
|
||
|
||
try {
|
||
const endpoint = editing ? "patch" : "add";
|
||
const method = editing ? "PATCH" : "POST";
|
||
await apiFetch(`/v3/config/paths/${endpoint}/${encodePathName(name)}`, {
|
||
method,
|
||
body: JSON.stringify(payload),
|
||
});
|
||
elements.pathDialog.close();
|
||
showToast(editing ? `已更新路径 ${name}` : `已添加路径 ${name}`);
|
||
await loadData();
|
||
} catch (error) {
|
||
elements.formAlert.textContent = error.message;
|
||
elements.formAlert.hidden = false;
|
||
} finally {
|
||
setButtonBusy(elements.pathSubmit, false, "", normalText);
|
||
}
|
||
}
|
||
|
||
function openDeleteDialog(name) {
|
||
state.deletingName = name;
|
||
elements.deletePathName.textContent = name;
|
||
elements.deleteAlert.hidden = true;
|
||
elements.deleteDialog.showModal();
|
||
window.setTimeout(() => elements.deleteSubmit.focus(), 0);
|
||
}
|
||
|
||
async function submitDelete(event) {
|
||
event.preventDefault();
|
||
const name = state.deletingName;
|
||
if (!name) return;
|
||
setButtonBusy(elements.deleteSubmit, true, "正在删除…", "删除路径");
|
||
elements.deleteAlert.hidden = true;
|
||
try {
|
||
await apiFetch(`/v3/config/paths/delete/${encodePathName(name)}`, { method: "DELETE" });
|
||
elements.deleteDialog.close();
|
||
showToast(`已删除路径 ${name}`);
|
||
await loadData();
|
||
} catch (error) {
|
||
elements.deleteAlert.textContent = error.message;
|
||
elements.deleteAlert.hidden = false;
|
||
} finally {
|
||
setButtonBusy(elements.deleteSubmit, false, "", "删除路径");
|
||
}
|
||
}
|
||
|
||
function extractPort(address, fallback) {
|
||
const match = String(address || "").match(/:(\d+)$/);
|
||
return match ? match[1] : String(fallback);
|
||
}
|
||
|
||
function browserHost() {
|
||
const hostname = window.location.hostname || "localhost";
|
||
return hostname.includes(":") ? `[${hostname}]` : hostname;
|
||
}
|
||
|
||
function encodedMediaPath(name) {
|
||
return name.split("/").map((part) => encodeURIComponent(part)).join("/");
|
||
}
|
||
|
||
function outputAddresses(name) {
|
||
const host = browserHost();
|
||
const path = encodedMediaPath(name);
|
||
const rtspPort = extractPort(state.global.rtspAddress, 8554);
|
||
const hlsPort = extractPort(state.global.hlsAddress, 8888);
|
||
const webrtcPort = extractPort(state.global.webrtcAddress, 8889);
|
||
const hlsScheme = state.global.hlsEncryption ? "https" : "http";
|
||
const webrtcScheme = state.global.webrtcEncryption ? "https" : "http";
|
||
return {
|
||
rtsp: `rtsp://${host}:${rtspPort}/${path}`,
|
||
webrtc: `${webrtcScheme}://${host}:${webrtcPort}/${path}`,
|
||
hls: `${hlsScheme}://${host}:${hlsPort}/${path}/`,
|
||
};
|
||
}
|
||
|
||
function renderOutputAddresses(addresses) {
|
||
const fragment = document.createDocumentFragment();
|
||
const entries = [
|
||
["RTSP", addresses.rtsp],
|
||
["WebRTC", addresses.webrtc],
|
||
["HLS", addresses.hls],
|
||
];
|
||
for (const [label, value] of entries) {
|
||
const item = create("div", { className: "output-item" });
|
||
const labelNode = create("label", { text: label });
|
||
const valueNode = create("span", { className: "output-value", text: value });
|
||
const copyButton = actionButton(`复制 ${label} 地址`, "button-secondary", () => copyText(value));
|
||
item.append(labelNode, valueNode, copyButton);
|
||
fragment.append(item);
|
||
}
|
||
elements.outputAddresses.replaceChildren(fragment);
|
||
}
|
||
|
||
function setPreviewProtocol(protocol) {
|
||
state.previewProtocol = protocol;
|
||
const addresses = outputAddresses(state.previewName);
|
||
const isWebRTC = protocol === "webrtc";
|
||
elements.webrtcTab.setAttribute("aria-selected", String(isWebRTC));
|
||
elements.hlsTab.setAttribute("aria-selected", String(!isWebRTC));
|
||
elements.previewFrame.src = isWebRTC ? addresses.webrtc : addresses.hls;
|
||
elements.previewHelp.textContent = isWebRTC
|
||
? "WebRTC 低延迟优先;连接失败时可切换到 HLS。"
|
||
: "HLS 兼容性优先,通常延迟高于 WebRTC。";
|
||
}
|
||
|
||
function openPreview(name) {
|
||
state.previewName = name;
|
||
elements.previewTitle.textContent = `预览 · ${name}`;
|
||
const addresses = outputAddresses(name);
|
||
renderOutputAddresses(addresses);
|
||
elements.previewDialog.showModal();
|
||
setPreviewProtocol("webrtc");
|
||
window.setTimeout(() => elements.webrtcTab.focus(), 0);
|
||
}
|
||
|
||
async function copyText(value) {
|
||
try {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(value);
|
||
} else {
|
||
const input = create("textarea");
|
||
input.value = value;
|
||
input.setAttribute("readonly", "");
|
||
input.className = "visually-hidden";
|
||
document.body.append(input);
|
||
input.select();
|
||
document.execCommand("copy");
|
||
input.remove();
|
||
}
|
||
showToast("地址已复制");
|
||
} catch (_) {
|
||
showToast("复制失败,请手动选择地址", true);
|
||
}
|
||
}
|
||
|
||
function showToast(message, danger = false) {
|
||
window.clearTimeout(state.toastTimer);
|
||
elements.toast.textContent = message;
|
||
elements.toast.style.background = danger ? "var(--danger)" : "var(--foreground)";
|
||
elements.toast.hidden = false;
|
||
state.toastTimer = window.setTimeout(() => {
|
||
elements.toast.hidden = true;
|
||
}, 4000);
|
||
}
|
||
|
||
function closeDialog(id) {
|
||
const dialog = byID(id);
|
||
if (dialog?.open) dialog.close();
|
||
}
|
||
|
||
function cacheElements() {
|
||
Object.assign(elements, {
|
||
skipLink: byID("skip-link"),
|
||
loginView: byID("login-view"),
|
||
loginForm: byID("login-form"),
|
||
loginUsername: byID("login-username"),
|
||
loginPassword: byID("login-password"),
|
||
loginPasswordToggle: byID("login-password-toggle"),
|
||
loginError: byID("login-error"),
|
||
loginSubmit: byID("login-submit"),
|
||
appView: byID("app-view"),
|
||
mainContent: byID("main-content"),
|
||
apiStatus: byID("api-status"),
|
||
refreshButton: byID("refresh-button"),
|
||
addButton: byID("add-button"),
|
||
logoutButton: byID("logout-button"),
|
||
statTotal: byID("stat-total"),
|
||
statOnline: byID("stat-online"),
|
||
statOffline: byID("stat-offline"),
|
||
statIdle: byID("stat-idle"),
|
||
searchInput: byID("search-input"),
|
||
statusFilter: byID("status-filter"),
|
||
pageAlert: byID("page-alert"),
|
||
pageAlertTitle: byID("page-alert-title"),
|
||
pageAlertMessage: byID("page-alert-message"),
|
||
alertRetryButton: byID("alert-retry-button"),
|
||
loadingState: byID("loading-state"),
|
||
emptyState: byID("empty-state"),
|
||
emptyTitle: byID("empty-title"),
|
||
emptyMessage: byID("empty-message"),
|
||
emptyAddButton: byID("empty-add-button"),
|
||
pathsContent: byID("paths-content"),
|
||
tableBody: byID("paths-table-body"),
|
||
cardList: byID("paths-card-list"),
|
||
paginationSummary: byID("pagination-summary"),
|
||
paginationPage: byID("pagination-page"),
|
||
pageSize: byID("page-size"),
|
||
previousPage: byID("previous-page"),
|
||
nextPage: byID("next-page"),
|
||
loadingText: byID("loading-text"),
|
||
pathDialog: byID("path-dialog"),
|
||
pathForm: byID("path-form"),
|
||
pathDialogTitle: byID("path-dialog-title"),
|
||
formAlert: byID("form-alert"),
|
||
pathName: byID("path-name"),
|
||
pathNameError: byID("path-name-error"),
|
||
sourceURL: byID("source-url"),
|
||
sourceToggle: byID("source-toggle"),
|
||
sourceRequired: byID("source-required"),
|
||
sourceHelp: byID("source-help"),
|
||
sourceError: byID("source-error"),
|
||
transport: byID("transport"),
|
||
sourceOnDemand: byID("source-on-demand"),
|
||
pathSubmit: byID("path-submit"),
|
||
deleteDialog: byID("delete-dialog"),
|
||
deleteForm: byID("delete-form"),
|
||
deletePathName: byID("delete-path-name"),
|
||
deleteAlert: byID("delete-alert"),
|
||
deleteSubmit: byID("delete-submit"),
|
||
previewDialog: byID("preview-dialog"),
|
||
previewTitle: byID("preview-title"),
|
||
previewFrame: byID("preview-frame"),
|
||
previewHelp: byID("preview-help"),
|
||
webrtcTab: byID("webrtc-tab"),
|
||
hlsTab: byID("hls-tab"),
|
||
outputAddresses: byID("output-addresses"),
|
||
toast: byID("toast"),
|
||
});
|
||
}
|
||
|
||
function bindEvents() {
|
||
elements.loginForm.addEventListener("submit", submitLogin);
|
||
elements.loginPasswordToggle.addEventListener("click", () => {
|
||
togglePassword(elements.loginPassword, elements.loginPasswordToggle);
|
||
});
|
||
elements.logoutButton.addEventListener("click", () => endSession());
|
||
elements.refreshButton.addEventListener("click", loadData);
|
||
elements.alertRetryButton.addEventListener("click", loadData);
|
||
elements.addButton.addEventListener("click", () => openPathDialog());
|
||
elements.emptyAddButton.addEventListener("click", () => openPathDialog());
|
||
elements.searchInput.addEventListener("input", () => {
|
||
state.currentPage = 1;
|
||
render();
|
||
});
|
||
elements.statusFilter.addEventListener("change", () => {
|
||
state.currentPage = 1;
|
||
render();
|
||
});
|
||
elements.pageSize.addEventListener("change", () => {
|
||
state.pageSize = Number(elements.pageSize.value);
|
||
state.currentPage = 1;
|
||
render();
|
||
});
|
||
elements.previousPage.addEventListener("click", () => {
|
||
state.currentPage -= 1;
|
||
render();
|
||
});
|
||
elements.nextPage.addEventListener("click", () => {
|
||
state.currentPage += 1;
|
||
render();
|
||
});
|
||
elements.pathForm.addEventListener("submit", submitPath);
|
||
elements.deleteForm.addEventListener("submit", submitDelete);
|
||
elements.sourceToggle.addEventListener("click", () => {
|
||
togglePassword(elements.sourceURL, elements.sourceToggle);
|
||
});
|
||
elements.webrtcTab.addEventListener("click", () => setPreviewProtocol("webrtc"));
|
||
elements.hlsTab.addEventListener("click", () => setPreviewProtocol("hls"));
|
||
|
||
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
|
||
button.addEventListener("click", () => closeDialog(button.dataset.closeDialog));
|
||
});
|
||
|
||
elements.previewDialog.addEventListener("close", () => {
|
||
elements.previewFrame.src = "about:blank";
|
||
state.previewName = null;
|
||
});
|
||
}
|
||
|
||
function initialize() {
|
||
cacheElements();
|
||
bindEvents();
|
||
restoreSession();
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", initialize);
|
||
})();
|