RTSP 路径
+仅显示静态 RTSP / RTSPS 源。地址中的密码始终遮盖。
+还没有 RTSP 路径
+添加第一个摄像头或 RTSP 服务地址,之后可在列表中预览与管理。
+ +| 路径 | +状态 | +源地址 | +传输 | +读取者 | +操作 | +
|---|
diff --git a/docs/maintainer/3-architecture-and-code-map.md b/docs/maintainer/3-architecture-and-code-map.md index d502a286..a38a3476 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: 998205ee17d1cdea34d6e68681f7cff8ac34b941 -synchronized_at: 2026-08-28T02:10:15Z +wiki_revision: a978bca7f5f9a686b33387bb8477c0adddf7a40c +synchronized_at: 2026-08-28T03:22:44Z # 架构与代码地图 @@ -20,21 +20,34 @@ synchronized_at: 2026-08-28T02:10:15Z - `api/openapi.yaml`:Control API 契约。 - `internal/**/*_test.go`:单元和集成测试。 -## 定制管理页面目标结构 +## 内嵌管理页面 ```text MediaMTX API HTTP 服务 :9997 -├─ /admin/ 嵌入式原生 HTML/CSS/JS -└─ /v3/... 现有 Control API +├─ /admin 301 规范化到 /admin/ +├─ /admin/ 内嵌 index.html +├─ /admin/app.css 内嵌原生样式 +├─ /admin/app.js 内嵌原生交互 +└─ /v3/... 现有 Control API -MediaMTX WebRTC :8889/{path} 低延迟预览 -MediaMTX HLS :8888/{path} 兼容预览 +MediaMTX WebRTC :8889/{path} 内置页面负责低延迟预览 +MediaMTX HLS :8888/{path}/ 内置页面负责兼容预览 ``` -管理页面只负责调用既有 Control API、展示状态和嵌入播放器,不代理 RTSP 媒体数据,不引入 Node、数据库或前端框架。 +管理页面只调用既有 Control API、展示状态并在 iframe 中打开 MediaMTX 内置播放器,不代理 RTSP 或 HTTP 媒体数据,不引入 Node、数据库或前端框架。 + +## 代码边界 + +- `internal/api/api.go`:注册 `/admin` 与 `/admin/*path`,路由位于现有 API 认证中间件之后。 +- `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`:配置/状态合并、CRUD、脱敏、筛选、预览地址和错误恢复。 +- `internal/api/api_admin_test.go`:入口、重定向、资源、CSP、鉴权和静态安全约束测试。 ## 主要 API +- `GET /v3/config/global/get` - `GET /v3/config/paths/list` - `POST /v3/config/paths/add/{name}` - `PATCH /v3/config/paths/patch/{name}` @@ -45,7 +58,7 @@ API 契约以当前仓库 `api/openapi.yaml` 为唯一代码级事实来源。 ## 修改边界 -- UI 静态资源与路由集中在 API 包的独立目录。 - 不改变 RTSP 拉流、转封装、协议协商等媒体核心行为。 -- 管理路由必须复用现有 API 认证边界。 -- 上游内置播放器优先复用,避免维护第二套媒体播放实现。 +- 管理路由复用现有 `action: api` 认证边界。 +- 预览复用上游内置 WebRTC/HLS 页面,避免维护第二套媒体播放实现。 +- 前端只保留管理静态 RTSP 源需要的最小配置字段;新增字段必须按工单和 API 契约扩展。 diff --git a/docs/maintainer/4-product-requirements-overview.md b/docs/maintainer/4-product-requirements-overview.md index 737e90ca..08d0cb13 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: 69f6a337a250440c7c5043c8d75122d8e5bfd0a6 -synchronized_at: 2026-08-28T02:48:30Z +wiki_revision: 66aa5cb6e1b5d1558fd5fe6dafd6f38fe3a6f5ae +synchronized_at: 2026-08-28T03:22:52Z # 产品需求总览 @@ -13,16 +13,16 @@ synchronized_at: 2026-08-28T02:48:30Z | 状态 | 需求 | 入口 | |---|---|---| | 已完成 | 增量接入 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) | +| 实现完成、待验收 | 原生轻量 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) | -## 管理页面目标 +## 管理页面能力 -- 使用原生 HTML、CSS 和 JavaScript。 -- 随 MediaMTX 二进制嵌入,不要求 Node、Nginx、数据库或单独 Web 服务。 -- 增加、编辑、删除静态 RTSP 源。 -- 展示路径在线/离线状态。 -- 提供 WebRTC 与 HLS 预览入口。 -- 可以复制 RTSP、HLS、WebRTC 输出地址。 +- 使用原生 HTML、CSS 和 JavaScript,并由 Go `embed` 随 MediaMTX 二进制嵌入。 +- API 启用后,由同一 API HTTP 服务在 `/admin/` 提供页面,不要求 Node、Nginx、数据库或单独 Web 服务。 +- 增加、编辑、删除静态 RTSP / RTSPS 源。 +- 展示路径在线、离线、按需待请求状态及读取者数量。 +- 默认提供 WebRTC 预览,并可切换 HLS。 +- 可以复制 RTSP、HLS、WebRTC 输出地址;端口来自当前全局配置。 - 保持界面小而直接,不扩展为完整 VMS。 ## 已确认设计基线 @@ -32,7 +32,15 @@ synchronized_at: 2026-08-28T02:48:30Z - Quant-UX App ID:`6a90f1d7191a826306a7f403`。 - 确认范围:桌面路径列表、添加、编辑、WebRTC/HLS 预览、删除确认、空/加载状态、API 不可达、HTTP 403 权限错误和手机端卡片布局。 - 原型校验:9 个画布、176 个组件、29 条交互连线,Quant-UX consistency verification 为 `PASS`。 -- 生产实现工单为 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3),以该原型和设计工单的文字边界为准;关键交互变化需重新确认。 +- 生产实现工单为 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3),实现完成后仍需用户明确验收;关键交互变化需重新确认。 + +## 安全和权限 + +- `/admin/` 和 Control API 共用 `action: api` 鉴权边界。 +- 页面列表和帮助文字只保存脱敏后的 RTSP 地址;编辑时源地址留空可保留既有凭据。 +- 页面不使用 localStorage 保存敏感值,不把动态数据直接注入 `innerHTML`。 +- 页面静态响应包含 CSP、nosniff、referrer policy 和同源 frame 限制。 +- 真实摄像头地址、用户名、密码和 Token 不进入源码、测试、Wiki 或工单。 ## 非目标 @@ -41,7 +49,8 @@ synchronized_at: 2026-08-28T02:48:30Z - 不复制 MediaMTX 所有全局配置为大型后台。 - 不允许匿名用户修改路径配置。 - 不承诺浏览器支持摄像头输出的所有编码。 +- 不由管理页代理媒体数据。 ## 验收入口 -具体页面、状态、权限和异常处理以对应单元工单及已确认原型为准;本页只维护长期需求索引,不替代单元工单。 +具体实现和验证证据以 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3) 为准;本页只维护长期需求索引,不替代单元工单。 diff --git a/docs/maintainer/5-local-development-and-verification.md b/docs/maintainer/5-local-development-and-verification.md index f027cf65..c7e14078 100644 --- a/docs/maintainer/5-local-development-and-verification.md +++ b/docs/maintainer/5-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Local-Development-and-Verification.- -wiki_revision: 9291a287f01fdf1258a74e4461ca747679518cce -synchronized_at: 2026-08-28T02:10:40Z +wiki_revision: 5515588b36721b79890d6a12b89f3d3d6e7cc426 +synchronized_at: 2026-08-28T03:23:29Z # 本地开发与验证 @@ -13,40 +13,77 @@ synchronized_at: 2026-08-28T02:10:40Z - 工作目录:`D:\github\mediamtx` - 主要终端:PowerShell - 语言:Go(版本以 `go.mod` 和上游 CI 为准) +- 管理页:原生 HTML/CSS/JavaScript,无运行时前端依赖 - DevHarness:Python 3 标准库 - 上游远端:`upstream=https://github.com/bluenviron/mediamtx.git` - 定制远端:`origin=https://git.ilapage.cn/OPC/mediamtx.git` +## 首次准备 + +上游通过 `go:generate` 生成被 Git 忽略的构建资源。新工作树执行: + +```powershell +go generate ./internal/servers/hls +go generate ./internal/core +``` + +HLS 下载器使用固定版本和 SHA-256 校验;生成的 `hls.min.js` 与 `VERSION` 不进入提交。 + ## 常用命令 ```powershell git status --short --branch -go test ./internal/api ./internal/core -go test ./... -go build ./... +node --check internal/api/admin/app.js +go test ./internal/api -run '^TestAdmin' -count=1 +go test ./... -run '^$' +go test -tags enable_linters ./internal/linters/docsorder python dev_scripts/harness.py check --strict python dev_scripts/harness.py sync --check python -m unittest discover -s tests -v +git diff --check ``` 预期: - Git 状态只包含当前工单和已知用户改动。 -- 受影响 Go 测试通过;全量测试按风险执行。 -- Go 构建成功。 +- 管理页路由、资源、安全头和鉴权测试通过。 +- 全部 Go 包可编译;全量测试按端口占用和风险执行。 - Harness 结构、镜像和单元测试通过。 -## 运行 +## 运行管理页面 -```powershell -go run . mediamtx.yml +在测试配置中启用 API: + +```yaml +api: yes +apiAddress: :9997 ``` -启用管理 API 时使用不含真实凭据的测试配置。Control API 默认端口为 `:9997`,WebRTC 为 `:8889`,HLS 为 `:8888`。 +运行 MediaMTX 后访问 `http://127.0.0.1:9997/admin/`。页面和 API 共用 `action: api` 权限;未授权浏览器会收到现有 Basic authentication challenge。 + +默认媒体入口: + +- RTSP:`:8554/{path}` +- HLS:`:8888/{path}/` +- WebRTC:`:8889/{path}` + +页面从 `/v3/config/global/get` 读取自定义端口,不写死部署主机名。启用 TLS 时由对应 HLS/WebRTC 配置决定预览协议。 + +## 浏览器检查 + +至少检查 1440×900 和 375×812: + +- 桌面表格与手机卡片切换,无横向溢出。 +- 所有表单有可见标签、就地错误和键盘焦点。 +- 主要操作高度至少 44px。 +- reduced-motion 生效,页面不请求第三方资源。 +- 新增、编辑留空保留源凭据、删除确认、WebRTC/HLS 切换和 API 断线重试。 +- DOM、帮助文字和通知中不存在完整 RTSP 密码。 +- 控制台无运行时错误。 ## 凭据 -`ip_camera.env` 只允许保存在本地并被 Git 忽略。测试、日志、截图、工单和 Wiki 使用虚构地址与账号。 +`ip_camera.env` 只允许保存在本地并被 Git 忽略。测试、日志、截图、工单和 Wiki 使用 `192.0.2.x` 等保留地址及虚构账号。 ## 未验证内容 diff --git a/docs/maintainer/6-common-changes.md b/docs/maintainer/6-common-changes.md index 21ee414f..1a53ffde 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: 17db8cd186ac676dd3c58c114751fc4f19483cfe -synchronized_at: 2026-08-28T02:10:42Z +wiki_revision: 713c7047d63271c8b345c163c3582d2caa20a2e2 +synchronized_at: 2026-08-28T03:23:37Z # 常见修改 @@ -11,10 +11,20 @@ synchronized_at: 2026-08-28T02:10:42Z ## 增加管理页字段 1. 先核对 `api/openapi.yaml` 和 `internal/conf/` 中的真实字段。 -2. 更新已确认的页面状态和验证范围。 -3. 修改原生表单及请求体。 -4. 增加前端输入校验和 Go 路由/响应测试。 -5. 验证新增、读取、编辑、删除及错误提示。 +2. 判断是否改变已确认的页面结构、主要流程、权限或异常处理;关键变化先更新原型并重新确认。 +3. 修改 `internal/api/admin/` 中的原生表单、脱敏逻辑和请求体。 +4. 保持可见标签、就地校验、键盘焦点、44px 触控目标和窄屏卡片布局。 +5. 增加前端静态约束、Go 路由/响应测试和浏览器场景检查。 +6. 验证新增、读取、编辑留空保留既有值、删除及错误恢复。 + +## 调整管理页资源或路由 + +- 静态资源使用 Go `embed`,集中在 `internal/api/admin/`。 +- `/admin/` 必须继续位于 API 鉴权中间件之后并使用 `action: api`。 +- 保持 CSP、nosniff、referrer policy、缓存策略和未知资源 404 测试。 +- 不引入 CDN、外部字体、Node 运行时、前端框架或独立 Web 服务。 +- 动态内容使用 `textContent` / DOM API,不把路径名、URL 或错误直接注入 `innerHTML`。 +- 新增媒体预览能力时优先复用 MediaMTX 内置播放页,不让管理页代理媒体流。 ## 调整 Control API @@ -24,9 +34,9 @@ API 属于中风险变更。必须先建工单并确认兼容性,不得只改 1. 记录目标上游完整提交。 2. 获取 `upstream`。 -3. 检查定制文件和 API 契约变化。 +3. 检查 `internal/api/admin/`、API 路由和配置 JSON 字段变化。 4. 合并时保留用户未提交改动。 -5. 运行受影响测试和构建。 +5. 运行管理页专项测试、全部包编译、受影响测试和 Harness 检查。 6. 更新项目档案中的上游基线并回写工单证据。 ## 修改文档 diff --git a/internal/api/admin/app.css b/internal/api/admin/app.css new file mode 100644 index 00000000..5cc126eb --- /dev/null +++ b/internal/api/admin/app.css @@ -0,0 +1,844 @@ +:root { + color-scheme: light; + --background: #fafafa; + --surface: #ffffff; + --surface-muted: #f4f4f5; + --foreground: #09090b; + --secondary: #52525b; + --border: #e4e4e7; + --border-strong: #a1a1aa; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --success: #166534; + --success-bg: #dcfce7; + --warning: #854d0e; + --warning-bg: #fef3c7; + --danger: #b91c1c; + --danger-hover: #991b1b; + --danger-bg: #fee2e2; + --focus: #1d4ed8; + --shadow: 0 12px 32px rgb(9 9 11 / 10%); + --radius: 10px; + --z-toast: 30; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--background); + font-family: "Segoe UI", Arial, sans-serif; + color: var(--foreground); +} + +body { + min-height: 100dvh; + margin: 0; + font-size: 16px; + line-height: 1.5; +} + +button, +input, +select { + font: inherit; +} + +button, +select, +input[type="checkbox"] { + cursor: pointer; + touch-action: manipulation; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +a:focus-visible { + outline: 3px solid rgb(37 99 235 / 35%); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 2px; + font-size: clamp(1.5rem, 2vw, 1.9rem); + line-height: 1.2; + letter-spacing: -0.02em; +} + +h2 { + margin-bottom: 4px; + font-size: 1.25rem; +} + +h3 { + margin-bottom: 8px; + font-size: 1rem; +} + +code { + font-family: Consolas, "Courier New", monospace; + font-size: 0.9em; +} + +.skip-link { + position: fixed; + z-index: var(--z-toast); + top: 8px; + left: 8px; + padding: 10px 14px; + border-radius: 6px; + color: #ffffff; + background: var(--foreground); + transform: translateY(-150%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + width: min(100% - 48px, 1360px); + margin: 0 auto; + padding: 28px 0 20px; +} + +.eyebrow { + margin-bottom: 2px; + color: var(--secondary); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.subtitle, +.panel-heading p, +.helper { + margin-bottom: 0; + color: var(--secondary); +} + +.subtitle { + font-size: 0.875rem; +} + +.topbar-actions, +.modal-actions, +.row-actions, +.preview-tabs { + display: flex; + align-items: center; + gap: 8px; +} + +.button, +.icon-button, +.tab { + min-height: 44px; + border: 1px solid transparent; + border-radius: 7px; + padding: 9px 14px; + font-weight: 650; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.button-primary { + border-color: var(--accent); + color: #ffffff; + background: var(--accent); +} + +.button-primary:hover { + background: var(--accent-hover); +} + +.button-secondary, +.icon-button, +.tab { + border-color: var(--border); + color: var(--foreground); + background: var(--surface); +} + +.button-secondary:hover, +.icon-button:hover, +.tab:hover { + background: var(--surface-muted); +} + +.button-danger { + border-color: var(--danger); + color: #ffffff; + background: var(--danger); +} + +.button-danger:hover { + background: var(--danger-hover); +} + +.icon-button { + min-width: 64px; +} + +.status-pill, +.path-status { + display: inline-flex; + align-items: center; + min-height: 30px; + border-radius: 999px; + padding: 4px 11px; + font-size: 0.8rem; + font-weight: 700; + white-space: nowrap; +} + +.status-pill::before, +.path-status::before { + width: 7px; + height: 7px; + margin-right: 7px; + border-radius: 50%; + background: currentColor; + content: ""; +} + +.status-loading, +.status-idle { + color: var(--warning); + background: var(--warning-bg); +} + +.status-ok, +.status-online { + color: var(--success); + background: var(--success-bg); +} + +.status-error, +.status-offline { + color: var(--danger); + background: var(--danger-bg); +} + +.page { + width: min(100% - 48px, 1360px); + margin: 0 auto; + padding-bottom: 48px; +} + +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin: 12px 0 24px; +} + +.stat-card, +.paths-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.stat-card { + min-height: 98px; + padding: 18px 20px; +} + +.stat-card span { + display: block; + margin-bottom: 6px; + color: var(--secondary); + font-size: 0.82rem; + font-weight: 650; +} + +.stat-card strong { + font-size: 1.75rem; + font-variant-numeric: tabular-nums; +} + +.paths-panel { + overflow: hidden; +} + +.panel-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + padding: 20px 22px; + border-bottom: 1px solid var(--border); +} + +.panel-heading p { + font-size: 0.82rem; +} + +.filters { + display: flex; + align-items: end; + gap: 10px; +} + +.filters label, +.field { + display: grid; + gap: 6px; +} + +.filters label > span, +.field > label { + font-size: 0.82rem; + font-weight: 650; +} + +input, +select { + min-height: 44px; + border: 1px solid var(--border-strong); + border-radius: 7px; + padding: 9px 11px; + color: var(--foreground); + background: var(--surface); +} + +input[readonly] { + cursor: default; + color: var(--secondary); + background: var(--surface-muted); +} + +.search-field input { + width: min(28vw, 280px); +} + +.page-alert, +.form-alert { + border: 1px solid #fecaca; + border-radius: 8px; + color: var(--danger); + background: #fff7f7; +} + +.page-alert { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 18px 22px 0; + padding: 14px 16px; +} + +.page-alert p, +.form-alert { + margin-bottom: 0; + font-size: 0.875rem; +} + +.loading-state, +.empty-state { + display: grid; + place-items: center; + min-height: 310px; + padding: 40px 20px; + text-align: center; +} + +.loading-state { + align-content: center; + grid-auto-flow: column; + gap: 12px; + color: var(--secondary); +} + +.spinner { + width: 20px; + height: 20px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 700ms linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.empty-state p { + max-width: 520px; + color: var(--secondary); +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +th, +td { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: middle; +} + +th { + color: var(--secondary); + background: var(--surface-muted); + font-size: 0.78rem; + font-weight: 700; +} + +th:nth-child(1) { width: 15%; } +th:nth-child(2) { width: 10%; } +th:nth-child(3) { width: 33%; } +th:nth-child(4) { width: 9%; } +th:nth-child(5) { width: 8%; } +th:nth-child(6) { width: 25%; } + +td { + font-size: 0.875rem; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.path-name { + font-weight: 700; + overflow-wrap: anywhere; +} + +.source-address { + display: block; + overflow: hidden; + color: var(--secondary); + font-family: Consolas, "Courier New", monospace; + font-size: 0.8rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.readers { + font-variant-numeric: tabular-nums; +} + +.row-actions { + justify-content: flex-end; + flex-wrap: wrap; +} + +.row-actions .button { + min-height: 38px; + padding: 6px 10px; + font-size: 0.8rem; +} + +.card-list { + display: none; +} + +.modal { + width: min(720px, calc(100% - 32px)); + max-height: calc(100dvh - 32px); + margin: auto; + border: 1px solid var(--border); + border-radius: 12px; + padding: 0; + color: var(--foreground); + background: var(--surface); + box-shadow: var(--shadow); +} + +.modal::backdrop { + background: rgb(9 9 11 / 55%); +} + +.modal form, +.modal > div:not(.modal-header), +.modal > p, +.modal > aside { + margin-right: 0; +} + +.modal form { + padding: 22px; +} + +.modal > .modal-header, +.modal > .preview-tabs, +.modal > .preview-layout { + margin-right: 22px; + margin-left: 22px; +} + +.modal > .modal-header { + margin-top: 22px; +} + +.modal-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.modal-header h2 { + margin-bottom: 0; +} + +.form-alert { + margin-bottom: 18px; + padding: 11px 13px; +} + +.field + .field, +.field-grid { + margin-top: 18px; +} + +.helper, +.field-error { + font-size: 0.78rem; +} + +.field-error { + min-height: 1.2em; + margin: 0; + color: var(--danger); + font-weight: 600; +} + +.password-field { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} + +.switch-field { + display: flex; + align-items: center; + min-height: 72px; + gap: 12px; + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + cursor: pointer; +} + +.switch-field input { + width: 22px; + min-height: 22px; + margin: 0; + accent-color: var(--accent); +} + +.switch-field strong, +.switch-field small { + display: block; +} + +.switch-field small { + color: var(--secondary); +} + +.modal-actions { + justify-content: flex-end; + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid var(--border); +} + +.modal-small { + width: min(520px, calc(100% - 32px)); +} + +.modal-small p { + line-height: 1.65; +} + +.danger-text { + color: var(--danger); +} + +.modal-wide { + width: min(1180px, calc(100% - 32px)); + padding-bottom: 22px; +} + +.preview-tabs { + margin-bottom: 14px; +} + +.tab[aria-selected="true"] { + border-color: var(--accent); + color: #ffffff; + background: var(--accent); +} + +.preview-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 310px; + gap: 18px; +} + +.preview-frame-wrap { + min-width: 0; +} + +#preview-frame { + display: block; + width: 100%; + min-height: 460px; + border: 0; + border-radius: 9px; + background: #111827; +} + +.preview-frame-wrap .helper { + margin-top: 8px; +} + +.output-panel { + border: 1px solid var(--border); + border-radius: 9px; + padding: 16px; + background: var(--surface-muted); +} + +.output-item + .output-item { + margin-top: 16px; +} + +.output-item label { + display: block; + margin-bottom: 5px; + font-size: 0.78rem; + font-weight: 700; +} + +.output-value { + display: block; + margin-bottom: 7px; + overflow-wrap: anywhere; + color: var(--secondary); + font-family: Consolas, "Courier New", monospace; + font-size: 0.76rem; +} + +.output-item .button { + width: 100%; +} + +.toast { + position: fixed; + z-index: var(--z-toast); + right: 20px; + bottom: 20px; + max-width: min(420px, calc(100% - 40px)); + border-radius: 8px; + padding: 13px 16px; + color: #ffffff; + background: var(--foreground); + box-shadow: var(--shadow); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +[hidden] { + display: none !important; +} + +@media (max-width: 900px) { + .topbar, + .panel-heading { + align-items: stretch; + flex-direction: column; + } + + .topbar-actions { + flex-wrap: wrap; + } + + .stats { + grid-template-columns: repeat(2, 1fr); + } + + .filters, + .filters label, + .search-field input { + width: 100%; + } + + .preview-layout { + grid-template-columns: 1fr; + } + + #preview-frame { + min-height: 380px; + } +} + +@media (max-width: 700px) { + .topbar, + .page { + width: min(100% - 32px, 1360px); + } + + .topbar { + padding-top: 20px; + } + + .topbar-actions .status-pill { + width: 100%; + justify-content: center; + } + + .topbar-actions .button { + flex: 1; + } + + .stats { + gap: 10px; + } + + .stat-card { + min-height: 88px; + padding: 14px; + } + + .panel-heading { + padding: 18px 16px; + } + + .filters { + align-items: stretch; + flex-direction: column; + } + + .table-wrap { + display: none; + } + + .card-list { + display: grid; + gap: 12px; + padding: 14px; + } + + .path-card { + border: 1px solid var(--border); + border-radius: 9px; + padding: 16px; + } + + .path-card-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .path-card .source-address { + margin-bottom: 12px; + overflow: visible; + text-overflow: initial; + white-space: normal; + } + + .path-card-meta { + margin-bottom: 14px; + color: var(--secondary); + font-size: 0.82rem; + } + + .row-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + } + + .row-actions .button { + min-height: 44px; + } + + .field-grid, + .password-field { + grid-template-columns: 1fr; + } + + .modal { + width: calc(100% - 20px); + max-height: calc(100dvh - 20px); + } + + .modal form { + padding: 18px; + } + + .modal > .modal-header, + .modal > .preview-tabs, + .modal > .preview-layout { + margin-right: 16px; + margin-left: 16px; + } + + .modal > .modal-header { + margin-top: 16px; + } + + #preview-frame { + min-height: 280px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/internal/api/admin/app.js b/internal/api/admin/app.js new file mode 100644 index 00000000..5a56c08e --- /dev/null +++ b/internal/api/admin/app.js @@ -0,0 +1,645 @@ +(() => { + "use strict"; + + const API = { + configs: "/v3/config/paths/list", + paths: "/v3/paths/list", + global: "/v3/config/global/get", + }; + + const state = { + configs: [], + runtime: new Map(), + global: {}, + editingName: null, + deletingName: null, + previewName: null, + previewProtocol: "webrtc", + loading: false, + toastTimer: null, + }; + + 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:••••@", + ); + } + + async function apiFetch(url, options = {}) { + const headers = new Headers(options.headers || {}); + if (options.body !== undefined) headers.set("Content-Type", "application/json"); + + let response; + try { + response = await fetch(url, { + ...options, + 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) { + 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 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; + setAPIStatus("loading", "正在连接 API"); + } + } + + async function loadData() { + if (state.loading) return; + setLoading(true); + hidePageError(); + + try { + const [configData, pathData, globalData] = await Promise.all([ + apiFetch(API.configs), + apiFetch(API.paths), + apiFetch(API.global).catch(() => ({})), + ]); + + state.configs = (configData.items || []) + .filter(isRTSPSource) + .map(scrubConfig); + state.runtime = new Map((pathData.items || []).map((item) => [item.name, item])); + state.global = globalData || {}; + + setAPIStatus("ok", "API 已连接"); + render(); + } catch (error) { + 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([]); + } 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; + }); + } + + 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); + 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 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; + } + + elements.emptyState.hidden = true; + elements.pathsContent.hidden = false; + renderTable(configs); + renderCards(configs); + } + + 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, { + apiStatus: byID("api-status"), + refreshButton: byID("refresh-button"), + addButton: byID("add-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"), + 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.refreshButton.addEventListener("click", loadData); + 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.pathForm.addEventListener("submit", submitPath); + elements.deleteForm.addEventListener("submit", submitDelete); + elements.sourceToggle.addEventListener("click", () => { + const showing = elements.sourceURL.type === "text"; + elements.sourceURL.type = showing ? "password" : "text"; + elements.sourceToggle.textContent = showing ? "显示" : "隐藏"; + elements.sourceToggle.setAttribute("aria-pressed", String(!showing)); + elements.sourceURL.focus(); + }); + 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(); + loadData(); + } + + document.addEventListener("DOMContentLoaded", initialize); +})(); diff --git a/internal/api/admin/index.html b/internal/api/admin/index.html new file mode 100644 index 00000000..e1d5b26c --- /dev/null +++ b/internal/api/admin/index.html @@ -0,0 +1,210 @@ + + +
+ + + +MediaMTX
+局域网 RTSP 路径管理与快速预览
+仅显示静态 RTSP / RTSPS 源。地址中的密码始终遮盖。
+添加第一个摄像头或 RTSP 服务地址,之后可在列表中预览与管理。
+ +| 路径 | +状态 | +源地址 | +传输 | +读取者 | +操作 | +
|---|