From 9068712bb13599c9e2dc8899f07ecf53c00cdc96 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 28 Aug 2026 16:48:17 +0800 Subject: [PATCH] feat(admin): add complete device pagination (#6) --- .../maintainer/3-architecture-and-code-map.md | 9 +- .../4-product-requirements-overview.md | 9 +- docs/maintainer/6-common-changes.md | 13 +- internal/api/admin/app.css | 76 ++++++++ internal/api/admin/app.js | 137 ++++++++++--- internal/api/admin/index.html | 19 +- internal/api/admin/list-tools.js | 182 ++++++++++++++++++ internal/api/api_admin_test.go | 9 + tests/admin_list_tools_test.js | 175 +++++++++++++++++ 9 files changed, 593 insertions(+), 36 deletions(-) create mode 100644 internal/api/admin/list-tools.js create mode 100644 tests/admin_list_tools_test.js diff --git a/docs/maintainer/3-architecture-and-code-map.md b/docs/maintainer/3-architecture-and-code-map.md index 315e9ef1..055136ef 100644 --- a/docs/maintainer/3-architecture-and-code-map.md +++ b/docs/maintainer/3-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Architecture-and-Code-Map.- -wiki_revision: 6991cffa6aea712d358645cf0942550bd277175a -synchronized_at: 2026-08-28T04:12:48Z +wiki_revision: 2b5014fc8d752f58255ebc4318acd8d798bc29be +synchronized_at: 2026-08-28T08:45:08Z # 架构与代码地图 @@ -28,6 +28,7 @@ MediaMTX API HTTP 服务 :9997 ├─ /admin/ 匿名加载内嵌登录外壳与 index.html ├─ /admin/app.css 内嵌原生样式 ├─ /admin/app.js 内嵌原生交互 +├─ /admin/list-tools.js 分批读取、完整集合与客户端分页工具 └─ /v3/... action: api 强制认证的 Control API MediaMTX WebRTC :8889/{path} 内置页面负责低延迟预览 @@ -42,7 +43,8 @@ MediaMTX HLS :8888/{path}/ 内置页面负责兼容预览 - `internal/api/api_admin.go`:Go `embed`、资源响应、缓存策略和安全响应头。 - `internal/api/admin/index.html`:语义化登录页、管理页、表单、对话框与无障碍 live regions。 - `internal/api/admin/app.css`:蓝灰设计 token、桌面表格、375px 卡片布局、焦点与 reduced-motion。 -- `internal/api/admin/app.js`:Basic 登录、当前标签页会话、401 清理、退出,以及配置/状态合并、CRUD、脱敏、筛选和预览。 +- `internal/api/admin/app.js`:Basic 登录、当前标签页会话、401 清理、退出,以及全量分批加载、配置/状态合并、CRUD、脱敏、筛选、客户端分页和预览。 +- `internal/api/admin/list-tools.js`:无运行时依赖的列表工具,负责共享并发上限、API 多页完整读取、稳定去重排序、完整统计和分页钳制;Node 测试可直接加载。 - `internal/api/api_admin_test.go`:入口、重定向、资源、CSP、鉴权和静态安全约束测试。 ## 主要 API @@ -62,3 +64,4 @@ API 契约以当前仓库 `api/openapi.yaml` 为唯一代码级事实来源。 - 管理静态资源只负责展示登录外壳;所有管理数据和写操作仍复用 `/v3` 的 `action: api` 认证边界。 - 预览复用上游内置 WebRTC/HLS 页面,避免维护第二套媒体播放实现。 - 前端只保留管理静态 RTSP 源需要的最小配置字段;新增字段必须按工单和 API 契约扩展。 +- 列表 API 固定以每页 100 条分批读取,配置与运行状态请求共用最多 4 个并发槽;完整快照成功后才替换页面数据,再执行统计、筛选和客户端分页。 diff --git a/docs/maintainer/4-product-requirements-overview.md b/docs/maintainer/4-product-requirements-overview.md index e17612cb..df7e3da3 100644 --- a/docs/maintainer/4-product-requirements-overview.md +++ b/docs/maintainer/4-product-requirements-overview.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements-Overview wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Product-Requirements-Overview.- -wiki_revision: 736e3cdfaa941073735475ac5a2b8962ed9ed566 -synchronized_at: 2026-08-28T04:12:53Z +wiki_revision: 2c63353ad6789d3155b136d7f96b8064cc66c36e +synchronized_at: 2026-08-28T08:45:15Z # 产品需求总览 @@ -15,6 +15,7 @@ synchronized_at: 2026-08-28T04:12:53Z | 已完成 | 增量接入 DevHarness | [工单 #1](https://git.ilapage.cn/OPC/mediamtx/issues/1) | | 实现完成、待验收 | 原生轻量 RTSP 管理页面 | [实现工单 #3](https://git.ilapage.cn/OPC/mediamtx/issues/3) · [设计工单 #2](https://git.ilapage.cn/OPC/mediamtx/issues/2) · [Quant-UX V1](https://qux.ilapage.cn/#/apps/6a90f1d7191a826306a7f403.html) | | 实施中 | 管理页轻量登录与 API 认证 | [工单 #4](https://git.ilapage.cn/OPC/mediamtx/issues/4) | +| 实现完成、待验收 | 管理页面设备分页与完整统计 | [工单 #6](https://git.ilapage.cn/OPC/mediamtx/issues/6) | ## 管理页面能力 @@ -26,6 +27,8 @@ synchronized_at: 2026-08-28T04:12:53Z - 可以复制 RTSP、HLS、WebRTC 输出地址;端口来自当前全局配置。 - 保持界面小而直接,不扩展为完整 VMS。 - 提供原生轻量登录、当前标签页会话和显式退出;账号权限来自 MediaMTX `authInternalUsers`。 +- 配置路径与运行路径按每批 100 条完整读取,并以共享并发上限 4 控制请求;统计、搜索和状态筛选覆盖全部设备。 +- 浏览器默认每页渲染 50 条,可切换 25 / 50 / 100,并显示范围、总数、当前页及上一页/下一页。 ## 已确认设计基线 @@ -57,4 +60,4 @@ synchronized_at: 2026-08-28T04:12:53Z ## 验收入口 -管理页面主体以 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3) 为准,登录与认证以 [#4](https://git.ilapage.cn/OPC/mediamtx/issues/4) 为准;本页只维护长期需求索引,不替代单元工单。 +管理页面主体以 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3) 为准,登录与认证以 [#4](https://git.ilapage.cn/OPC/mediamtx/issues/4) 为准,分页与完整统计以 [#6](https://git.ilapage.cn/OPC/mediamtx/issues/6) 为准;本页只维护长期需求索引,不替代单元工单。 diff --git a/docs/maintainer/6-common-changes.md b/docs/maintainer/6-common-changes.md index 1a53ffde..42b7bd94 100644 --- a/docs/maintainer/6-common-changes.md +++ b/docs/maintainer/6-common-changes.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Common-Changes wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Common-Changes.- -wiki_revision: 713c7047d63271c8b345c163c3582d2caa20a2e2 -synchronized_at: 2026-08-28T03:23:37Z +wiki_revision: dad147556fe40e39f0d73131830f8b38ce8e9d55 +synchronized_at: 2026-08-28T08:45:31Z # 常见修改 @@ -26,6 +26,15 @@ synchronized_at: 2026-08-28T03:23:37Z - 动态内容使用 `textContent` / DOM API,不把路径名、URL 或错误直接注入 `innerHTML`。 - 新增媒体预览能力时优先复用 MediaMTX 内置播放页,不让管理页代理媒体流。 +## 调整管理页设备列表与分页 + +- 配置路径和运行路径都必须按 Control API 分页完整读取;当前基线为每批 100 条、共享并发上限 4。 +- 完整快照成功后才能替换页面数据;任一批次失败不得提交部分统计或部分列表。 +- 先在全部静态 RTSP 配置上计算统计和筛选,再做客户端分页;分页只限制 DOM 渲染数量。 +- 默认每页 50 条,可选 25 / 50 / 100;搜索、状态或每页数量变化回到第一页,CRUD 与刷新后钳制到有效页。 +- 修改此流程时覆盖 0、边界数量、至少 235 条、多页失败、共享并发上限和窄屏无溢出场景。 +- 保持原生 HTML/CSS/JavaScript 和安全 DOM API,不引入前端运行时依赖。 + ## 调整 Control API API 属于中风险变更。必须先建工单并确认兼容性,不得只改前端假定接口。实现后更新 OpenAPI、处理器测试和相关长期文档。 diff --git a/internal/api/admin/app.css b/internal/api/admin/app.css index 03124dcc..e37212e7 100644 --- a/internal/api/admin/app.css +++ b/internal/api/admin/app.css @@ -530,6 +530,48 @@ tbody tr:last-child td { font-size: 0.8rem; } +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 52px; + padding: 4px 14px; + border-top: 1px solid var(--border); + color: var(--secondary); + background: var(--surface); +} + +.pagination-summary { + margin: 0; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} + +.pagination-controls, +.pagination-controls label { + display: flex; + align-items: center; + gap: 8px; +} + +.pagination-controls label > span, +.pagination-page { + font-size: 0.82rem; + font-weight: 650; + white-space: nowrap; +} + +.pagination-controls select { + min-width: 72px; +} + +.pagination-page { + min-width: 64px; + text-align: center; + font-variant-numeric: tabular-nums; +} + .card-list { display: none; } @@ -845,6 +887,40 @@ tbody tr:last-child td { display: grid; } + .pagination { + align-items: stretch; + flex-direction: column; + gap: 8px; + padding: 8px 12px; + } + + .pagination-summary { + text-align: center; + } + + .pagination-controls { + justify-content: center; + flex-wrap: wrap; + gap: 6px; + } + + .pagination-controls label { + gap: 4px; + } + + .pagination-controls select { + min-width: 64px; + padding-inline: 8px; + } + + .pagination-controls .button { + padding-inline: 10px; + } + + .pagination-page { + min-width: 56px; + } + .table-wrap { display: none; } diff --git a/internal/api/admin/app.js b/internal/api/admin/app.js index 3dbb0568..a9c2fc10 100644 --- a/internal/api/admin/app.js +++ b/internal/api/admin/app.js @@ -1,6 +1,10 @@ (() => { "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", @@ -21,6 +25,8 @@ loading: false, toastTimer: null, authorization: "", + currentPage: 1, + pageSize: 50, }; const elements = {}; @@ -288,7 +294,7 @@ function showPageError(error) { elements.pageAlert.hidden = false; - elements.pageAlertTitle.textContent = error.status === 403 ? "没有操作权限" : "无法加载路径"; + elements.pageAlertTitle.textContent = error.status === 403 ? "没有操作权限" : "无法完整加载设备"; elements.pageAlertMessage.textContent = error.message; setAPIStatus("error", error.status === 403 ? "API 权限不足" : "API 连接失败"); } @@ -306,58 +312,98 @@ 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([ - apiFetch(API.configs), - apiFetch(API.paths), + 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(() => ({})), ]); - state.configs = (configData.items || []) + const configs = ListTools.uniqueSortedByName(configData.items) .filter(isRTSPSource) .map(scrubConfig); - state.runtime = new Map((pathData.items || []).map((item) => [item.name, item])); + 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) { - if (error instanceof APIError && error.status === 401) return; - state.configs = []; - state.runtime = new Map(); - elements.pathsContent.hidden = true; - elements.emptyState.hidden = true; - showPageError(error instanceof APIError ? error : new APIError(0, String(error))); - updateStats([]); + 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() { - const query = elements.searchInput.value.trim().toLocaleLowerCase(); - const filter = elements.statusFilter.value; - return state.configs.filter((config) => { - const matchesQuery = !query || config.name.toLocaleLowerCase().includes(query) - || config.sourceMasked.toLocaleLowerCase().includes(query); - const matchesStatus = filter === "all" || getPathState(config).key === filter; - return matchesQuery && matchesStatus; - }); + return ListTools.filterConfigs( + state.configs, + elements.searchInput.value, + elements.statusFilter.value, + (config) => getPathState(config).key, + ); } function updateStats(configs) { - const counts = { online: 0, offline: 0, idle: 0 }; - for (const config of configs) counts[getPathState(config).key] += 1; - elements.statTotal.textContent = String(configs.length); + 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); @@ -429,6 +475,16 @@ 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(); @@ -445,10 +501,12 @@ return; } + const pageData = ListTools.paginateItems(configs, state.currentPage, state.pageSize); elements.emptyState.hidden = true; elements.pathsContent.hidden = false; - renderTable(configs); - renderCards(configs); + renderTable(pageData.items); + renderCards(pageData.items); + updatePagination(pageData); } function transportLabel(value) { @@ -736,6 +794,12 @@ 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"), @@ -776,8 +840,27 @@ elements.alertRetryButton.addEventListener("click", loadData); elements.addButton.addEventListener("click", () => openPathDialog()); elements.emptyAddButton.addEventListener("click", () => openPathDialog()); - elements.searchInput.addEventListener("input", render); - elements.statusFilter.addEventListener("change", render); + 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", () => { diff --git a/internal/api/admin/index.html b/internal/api/admin/index.html index 6c3f08d4..7f9e20a9 100644 --- a/internal/api/admin/index.html +++ b/internal/api/admin/index.html @@ -6,6 +6,7 @@