Files
StreamHall/public/admin.html
T
Stardream 326101958a
Build and Push Docker Image / build (push) Successful in 32s
Initial release
2026-05-20 15:25:51 +10:00

4229 lines
192 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin</title>
<script>
window.__echartsReady = new Promise((resolve) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js';
s.onload = () => resolve(true);
s.onerror = () => resolve(false);
document.head.appendChild(s);
});
</script>
<style>
:root {
--line: rgba(32, 44, 70, 0.13);
--text: #182033;
--muted: #667085;
--blue: #4e7ee8;
--mint: #19b8b1;
--rose: #f26389;
--gold: #f3b33d;
--panel: rgba(255, 255, 255, 0.9);
--input: rgba(255, 255, 255, 0.92);
}
:root[data-theme="dark"] {
color-scheme: dark;
--line: rgba(148, 163, 184, 0.2);
--text: #f8fafc;
--muted: #aeb8c8;
--blue: #93c5fd;
--mint: #99f6e4;
--panel: rgba(24, 31, 48, 0.88);
--input: rgba(30, 41, 59, 0.92);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(135deg, rgba(246, 249, 255, 0.98), rgba(255, 246, 250, 0.92) 50%, rgba(242, 255, 252, 0.96)),
linear-gradient(90deg, rgba(78, 126, 232, 0.08), rgba(242, 99, 137, 0.07));
color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
}
:root[data-theme="dark"] body {
background:
linear-gradient(135deg, rgba(15, 20, 34, 0.98), rgba(28, 35, 54, 0.94) 52%, rgba(30, 24, 42, 0.98)),
linear-gradient(90deg, rgba(25, 184, 177, 0.1), rgba(242, 99, 137, 0.08));
color: #d8deea;
}
.hidden {
display: none !important;
}
.page-shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 34px;
}
.site-nav,
.admin-menu,
.admin-section,
.modal-card,
.stream-row {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 14px 34px rgba(58, 72, 102, 0.08);
backdrop-filter: blur(14px);
}
:root[data-theme="dark"] .site-nav,
:root[data-theme="dark"] .admin-menu,
:root[data-theme="dark"] .admin-section,
:root[data-theme="dark"] .modal-card,
:root[data-theme="dark"] .stream-row {
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.24);
}
.site-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 12px 14px;
}
.site-brand {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 1.05rem;
font-weight: 900;
text-decoration: none;
}
.site-brand::before {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
content: "";
background: linear-gradient(135deg, var(--mint), var(--rose));
box-shadow: 0 0 0 5px rgba(25, 184, 177, 0.12);
}
.site-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.hero-panel {
padding: 54px 0 30px;
}
.hero-panel::after {
display: block;
width: 100%;
height: 4px;
margin-top: 28px;
border-radius: 999px;
content: "";
background: linear-gradient(90deg, var(--mint), var(--rose), var(--gold), var(--blue));
}
.hero-panel h1 {
margin: 0;
color: var(--text);
font-size: 4rem;
font-weight: 900;
line-height: 1.05;
}
.hero-panel p {
margin: 16px 0 0;
color: var(--muted);
font-size: 1.05rem;
line-height: 1.8;
}
.admin-layout {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
align-items: start;
gap: 18px;
}
.admin-menu {
position: sticky;
top: 16px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
}
.admin-menu-btn {
border: 1px solid transparent;
border-radius: 6px;
padding: 12px 13px;
color: var(--muted);
background: transparent;
text-align: left;
white-space: nowrap;
}
.admin-menu-btn:hover {
border-color: rgba(78, 126, 232, 0.22);
color: var(--blue);
background: rgba(78, 126, 232, 0.07);
}
.admin-menu-btn.active {
color: #fff;
background: #4e7ee8;
box-shadow: 0 10px 22px rgba(78, 126, 232, 0.22);
}
.admin-menu-group { display:flex; flex-direction:column; }
.admin-menu-group-btn { border:1px solid transparent; border-radius:6px; padding:12px 13px; color:var(--muted); background:transparent; text-align:left; white-space:nowrap; cursor:pointer; font-size:inherit; width:100%; display:flex; align-items:center; justify-content:space-between; }
.admin-menu-group-btn:hover { border-color:rgba(78,126,232,.22); color:var(--blue); background:rgba(78,126,232,.07); }
.admin-menu-group.in-view .admin-menu-group-btn { border-color:rgba(78,126,232,.22); color:var(--blue); }
.group-arrow { font-size:1rem; opacity:.8; transition:transform .2s ease; flex-shrink:0; }
.admin-menu-group.open .group-arrow { transform:rotate(180deg); }
.admin-sub-menu { display:none; }
.admin-menu-group.open .admin-sub-menu { display:flex; flex-direction:column; gap:6px; padding-top:4px; }
.admin-sub-btn { border:1px solid transparent; border-radius:6px; padding:12px 13px 12px 24px; background:transparent; color:var(--muted); font-size:inherit; text-align:left; cursor:pointer; width:100%; white-space:nowrap; }
.admin-sub-btn:hover { border-color:rgba(78,126,232,.22); color:var(--blue); background:rgba(78,126,232,.07); }
.admin-sub-btn.active { color:#fff; background:#4e7ee8; box-shadow:0 6px 16px rgba(78,126,232,.2); }
.admin-content {
min-width: 0;
}
.admin-view {
display: none;
margin-bottom: 0;
}
.admin-view.active {
display: block;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(246, 249, 255, 0.84);
backdrop-filter: blur(5px);
}
:root[data-theme="dark"] .modal-overlay {
background: rgba(0, 0, 0, 0.72);
}
.modal-card {
width: min(100%, 400px);
padding: 28px;
}
.auth-card {
text-align: center;
}
.auth-card h2 {
margin: 0;
}
.auth-card .hint {
margin: 12px 0 0;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
h2,
h3 {
color: var(--text);
}
.section-kicker {
margin: 0 0 8px;
color: var(--rose);
font-size: 0.78rem;
font-weight: 800;
}
.admin-section > h2 {
margin-top: 0;
}
.admin-section {
margin-bottom: 28px;
padding: 24px;
}
.security-panel {
margin-top: 24px;
border-top: 1px solid var(--line);
padding-top: 22px;
}
.security-panel h3 {
margin: 0 0 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-weight: 700;
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 11px;
color: var(--text);
background: var(--input);
font: inherit;
outline: none;
}
textarea {
resize: vertical;
}
.link-row {
display: grid;
grid-template-columns: 1.2fr 0.8fr 1.8fr 1.4fr 1.6fr auto;
gap: 8px;
align-items: start;
margin-bottom: 10px;
}
.nav-link-row {
display: grid;
grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1.5fr) auto;
gap: 8px;
align-items: start;
margin-bottom: 10px;
}
.url-field {
min-width: 0;
}
.stream-check-status,
.stream-live-state {
min-height: 18px;
margin-top: 6px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
line-height: 1.45;
}
.stream-live-state {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 9px;
background: rgba(100, 116, 139, 0.08);
}
.stream-check-status.is-checking,
.stream-live-state.is-checking {
color: var(--blue);
background: rgba(78, 126, 232, 0.08);
}
.stream-check-status.is-online,
.stream-live-state.is-online {
color: #0f766e;
background: rgba(25, 184, 177, 0.1);
}
.stream-check-status.is-offline,
.stream-live-state.is-offline {
color: #dc2626;
background: rgba(220, 38, 38, 0.08);
}
:root[data-theme="dark"] .stream-check-status.is-online,
:root[data-theme="dark"] .stream-live-state.is-online {
color: var(--mint);
}
:root[data-theme="dark"] .stream-check-status.is-offline,
:root[data-theme="dark"] .stream-live-state.is-offline {
color: #fb7185;
}
.obs-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.obs-copy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.obs-route-panel {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.obs-route-form {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
gap: 10px;
align-items: end;
margin-top: 12px;
}
.obs-route-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.obs-route-card {
display: grid;
gap: 10px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.48);
}
:root[data-theme="dark"] .obs-route-card {
background: rgba(15, 23, 42, 0.34);
}
.obs-route-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--text);
font-weight: 900;
overflow-wrap: anywhere;
}
.obs-route-links {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.telegram-toggle-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 16px 0;
}
.check-line {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
color: var(--text);
font-weight: 800;
}
.check-line input {
width: auto;
margin: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
}
button,
.button-link {
border: 0;
border-radius: 6px;
padding: 10px 14px;
color: #fff;
background: #64748b;
font: inherit;
font-weight: 800;
cursor: pointer;
text-decoration: none;
transition: filter 0.2s ease, transform 0.2s ease;
}
button:hover,
.button-link:hover {
filter: brightness(1.06);
transform: translateY(-1px);
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: 1px solid rgba(78, 126, 232, 0.26);
border-radius: 999px;
color: #365bd6;
background:
radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.96), rgba(227, 236, 255, 0.94) 58%, rgba(190, 211, 255, 0.9));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 8px 18px rgba(78, 126, 232, 0.16);
padding: 0;
font-size: 1.08rem;
line-height: 1;
}
.theme-toggle:hover {
border-color: rgba(78, 126, 232, 0.36);
background:
radial-gradient(circle at 34% 28%, #fff, rgba(219, 231, 255, 0.98) 58%, rgba(169, 197, 255, 0.94));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
0 10px 22px rgba(78, 126, 232, 0.22);
}
:root[data-theme="dark"] .theme-toggle {
border-color: rgba(253, 230, 138, 0.3);
color: #fde68a;
background:
radial-gradient(circle at 38% 30%, rgba(253, 230, 138, 0.42), rgba(73, 61, 72, 0.9) 58%, rgba(31, 41, 59, 0.98));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 22px rgba(253, 186, 116, 0.14);
}
:root[data-theme="dark"] .theme-toggle:hover {
border-color: rgba(253, 230, 138, 0.46);
background:
radial-gradient(circle at 38% 30%, rgba(254, 240, 138, 0.5), rgba(86, 71, 74, 0.94) 58%, rgba(38, 50, 70, 0.98));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 10px 26px rgba(253, 186, 116, 0.2);
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.62s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
mix-blend-mode: normal;
}
::view-transition-old(root) {
animation-name: theme-hold;
}
::view-transition-new(root) {
animation-name: theme-ripple;
clip-path: circle(0 at var(--theme-ripple-x, 50%) var(--theme-ripple-y, 50%));
}
@keyframes theme-ripple {
to {
clip-path: circle(150vmax at var(--theme-ripple-x, 50%) var(--theme-ripple-y, 50%));
}
}
@keyframes theme-hold {
to {
opacity: 1;
}
}
.btn-primary {
background: #4e7ee8;
}
.btn-danger {
background: #dc2626;
}
.btn-success {
background: #16a34a;
}
.btn-warning {
background: #b45309;
}
.btn-secondary {
background: #64748b;
}
#admin-stream-list {
position: relative;
overflow: visible;
}
.stream-list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin: 12px 0 14px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
}
:root[data-theme="dark"] .stream-list-toolbar {
background: rgba(15, 23, 42, 0.42);
}
.stream-list-filters {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(150px, 0.32fr) minmax(180px, 0.42fr) auto;
gap: 10px;
margin: 16px 0 10px;
}
.stream-list-filters input,
.stream-list-filters select {
height: 42px;
}
.stream-list-filters button {
height: 42px;
white-space: nowrap;
}
.stream-list-count,
.page-info {
color: var(--muted);
font-size: 0.84rem;
font-weight: 800;
white-space: nowrap;
}
.page-size-control,
.pagination-control {
display: inline-flex;
align-items: center;
gap: 8px;
}
.page-size-control label {
display: inline-flex;
align-items: center;
align-self: stretch;
margin: 0;
line-height: 1;
}
.page-size-control select {
width: auto;
min-width: 86px;
padding: 8px 30px 8px 10px;
}
.pagination-control button {
padding: 8px 11px;
}
.stream-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(0, 1fr) 260px;
align-items: center;
gap: 14px;
padding: 16px;
margin-bottom: 12px;
overflow: visible;
}
.stream-row.menu-open {
z-index: 40;
}
.stream-main {
min-width: 0;
}
.stream-name {
color: var(--text);
font-weight: 900;
line-height: 1.5;
overflow-wrap: anywhere;
}
.stream-meta {
margin-top: 5px;
color: var(--muted);
font-size: 0.82rem;
}
.stream-actions {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 0;
min-width: 0;
}
.stream-actions button,
.more-btn {
padding-right: 10px;
padding-left: 10px;
white-space: nowrap;
}
.action-menu {
position: relative;
}
.action-menu[open] {
z-index: 41;
}
.more-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
padding-top: 10px;
padding-bottom: 10px;
color: #fff;
background: #64748b;
font: inherit;
font-weight: 800;
cursor: pointer;
list-style: none;
transition: filter 0.2s ease, transform 0.2s ease;
}
.more-btn::-webkit-details-marker {
display: none;
}
.action-menu[open] .more-btn,
.more-btn:hover {
filter: brightness(1.06);
transform: translateY(-1px);
}
.action-menu-panel {
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 42;
display: grid;
width: 168px;
gap: 6px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px;
background: var(--panel);
box-shadow: 0 18px 40px rgba(58, 72, 102, 0.18);
}
.action-menu-panel button {
width: 100%;
padding: 9px 10px;
text-align: left;
}
.badge {
display: inline-block;
border-radius: 999px;
margin-left: 6px;
padding: 2px 8px;
color: #fff;
background: #be123c;
font-size: 0.72rem;
font-weight: 900;
}
.badge-key {
background: #b45309;
}
.badge-tg {
background: #0f766e;
}
.badge-closed {
background: #64748b;
}
.hint {
color: var(--muted);
font-size: 0.82rem;
}
.status {
margin-top: 8px;
color: #16a34a;
font-size: 0.9rem;
}
.status.error {
color: #dc2626;
}
@media (max-width: 900px) {
.form-grid,
.link-row,
.nav-link-row,
.obs-grid,
.telegram-toggle-grid {
grid-template-columns: 1fr;
}
.admin-layout {
grid-template-columns: 1fr;
}
.admin-menu {
position: static;
display: grid;
grid-template-columns: repeat(5, minmax(118px, 1fr));
overflow-x: auto;
}
.admin-menu-btn {
text-align: center;
}
.admin-menu-group-btn { justify-content:center; }
.admin-sub-btn { text-align:center; padding-left:13px; }
.stream-row {
align-items: flex-start;
grid-template-columns: auto 1fr;
}
.stream-row .stream-actions {
grid-column: 1 / -1;
}
.stream-actions {
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
}
.stream-list-toolbar {
align-items: stretch;
flex-direction: column;
}
.stream-list-filters {
grid-template-columns: 1fr;
}
.page-size-control,
.pagination-control {
justify-content: space-between;
width: 100%;
}
.obs-route-form,
.obs-route-links {
grid-template-columns: 1fr;
}
.action-menu-panel {
right: auto;
left: 0;
}
}
@media (max-width: 768px) {
.page-shell {
width: min(100% - 24px, 1180px);
padding: 18px 0;
}
.site-nav {
align-items: flex-start;
flex-direction: column;
}
.hero-panel {
padding: 36px 0 24px;
}
.hero-panel h1 {
font-size: 2.55rem;
}
}
/* ── Dashboard / Stats ───────────────────────────────────────── */
.dash-card-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:20px; }
@media(max-width:900px) { .dash-card-grid { grid-template-columns:repeat(2,1fr); } }
.dash-card { border:1px solid var(--line); border-radius:8px; background:var(--panel); padding:16px 18px; backdrop-filter:blur(14px); }
:root[data-theme="dark"] .dash-card { box-shadow:0 10px 26px rgba(0,0,0,.2); }
.dash-card-label { font-size:.75rem; font-weight:800; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
.dash-card-value { font-size:2.1rem; font-weight:900; line-height:1.1; margin:4px 0 2px; }
.dash-card-sub { font-size:.75rem; color:var(--muted); }
.dash-accent-mint .dash-card-value { color:var(--mint); }
.dash-accent-blue .dash-card-value { color:var(--blue); }
.dash-accent-rose .dash-card-value { color:var(--rose); }
.dash-accent-gold .dash-card-value { color:var(--gold); }
.dash-two-col { display:grid; grid-template-columns:240px minmax(0,1fr); gap:14px; margin-bottom:20px; align-items:start; }
@media(max-width:768px) { .dash-two-col { grid-template-columns:1fr; } }
.dash-hbar-list { display:flex; flex-direction:column; gap:10px; margin-top:8px; }
.dash-hbar-row { display:flex; align-items:center; gap:8px; }
.dash-hbar-icon { width:18px; text-align:center; font-size:.9rem; flex-shrink:0; }
.dash-hbar-lbl { width:46px; font-size:.78rem; color:var(--muted); font-weight:700; flex-shrink:0; }
.dash-hbar-track { flex:1; height:9px; background:rgba(100,116,139,.12); border-radius:999px; overflow:hidden; }
.dash-hbar-fill { height:100%; border-radius:999px; transition:width .5s cubic-bezier(.22,1,.36,1); }
.dash-hbar-pct { width:34px; text-align:right; font-size:.78rem; font-weight:800; color:var(--muted); flex-shrink:0; }
.dash-range-tabs { display:flex; gap:5px; flex-wrap:wrap; }
.dash-range-btn { border:1px solid var(--line); border-radius:5px; padding:4px 10px; color:var(--muted); background:transparent; font-size:.78rem; font-weight:800; cursor:pointer; box-shadow:none; transform:none !important; }
.dash-range-btn:hover { color:var(--blue); border-color:rgba(78,126,232,.3); background:rgba(78,126,232,.06); filter:none; }
.dash-range-btn.active { color:#fff; background:var(--blue); border-color:transparent; }
.dash-bar-chart { display:flex; align-items:flex-end; gap:2px; height:110px; padding-bottom:3px; }
.dash-bar-col-wrap { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; height:100%; position:relative; }
.dash-bar-col { width:100%; min-height:2px; border-radius:3px 3px 0 0; background:var(--blue); opacity:.75; transition:height .4s cubic-bezier(.22,1,.36,1); cursor:default; }
.dash-bar-col:hover { opacity:1; }
.dash-bar-col-wrap:hover .dash-bar-tip { opacity:1; transform:translateX(-50%) translateY(0); }
.dash-bar-tip { position:absolute; bottom:calc(100% + 4px); left:50%; transform:translateX(-50%) translateY(4px); background:rgba(24,32,51,.92); color:#fff; font-size:.68rem; font-weight:800; padding:2px 6px; border-radius:4px; white-space:nowrap; opacity:0; transition:opacity .15s,transform .15s; pointer-events:none; z-index:10; }
.dash-bar-axis { display:flex; gap:2px; margin-top:4px; }
.dash-bar-axis-lbl { flex:1; text-align:center; font-size:.58rem; color:var(--muted); overflow:visible; white-space:nowrap; }
.dash-table { width:100%; border-collapse:collapse; font-size:.86rem; }
.dash-table th { padding:7px 9px; text-align:left; color:var(--muted); font-size:.72rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; border-bottom:1px solid var(--line); white-space:nowrap; }
.dash-table td { padding:10px 9px; border-bottom:1px solid var(--line); vertical-align:middle; }
.dash-table tr:last-child td { border-bottom:none; }
.dash-table tr:hover td { background:rgba(78,126,232,.04); }
.dash-table th[data-sort-col] { cursor:pointer; user-select:none; }
.dash-table th[data-sort-col]:hover { color:var(--fg); }
.dash-table th[data-sort-col]:not(.sort-asc):not(.sort-desc)::after { content:' ↕'; opacity:.28; }
.dash-table th.sort-asc::after { content:' ↑'; opacity:.7; }
.dash-table th.sort-desc::after { content:' ↓'; opacity:.7; }
.dash-td-name { font-weight:700; max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dash-td-name-link { cursor:pointer; }
.dash-td-name-link:hover { color:var(--blue); text-decoration:underline; text-underline-offset:3px; }
.dash-td-num { text-align:right; font-weight:800; white-space:nowrap; }
.dash-td-online { color:var(--mint); }
.dash-td-bar { display:flex; align-items:center; gap:7px; }
.dash-td-bar-num { font-weight:800; min-width:28px; text-align:right; }
.dash-td-bar-track { flex:1; min-width:44px; height:5px; background:rgba(100,116,139,.14); border-radius:999px; overflow:hidden; }
.dash-td-bar-fill { height:100%; border-radius:999px; background:var(--blue); opacity:.7; }
.dash-dev-badges { display:flex; gap:3px; flex-wrap:wrap; }
.dash-dev-badge { font-size:.67rem; font-weight:800; padding:2px 5px; border-radius:999px; border:1px solid var(--line); color:var(--muted); background:rgba(100,116,139,.08); white-space:nowrap; }
.dash-dev-badge.mobile { color:var(--rose); background:rgba(242,99,137,.08); border-color:rgba(242,99,137,.2); }
.dash-dev-badge.desktop { color:var(--blue); background:rgba(78,126,232,.08); border-color:rgba(78,126,232,.2); }
.dash-dev-badge.tablet { color:var(--gold); background:rgba(243,179,61,.08); border-color:rgba(243,179,61,.2); }
.dash-muted { color:var(--muted); font-size:.8rem; }
.dash-toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; margin-bottom:16px; }
.dash-refresh-info { font-size:.8rem; color:var(--muted); }
.dash-countdown { font-weight:800; color:var(--blue); }
.modal-card-wide { width:min(100%,clamp(640px,80vw,1080px)); max-height:90vh; overflow-y:auto; padding:26px; }
.stream-stat-summary-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:14px 0 18px; }
@media(max-width:600px) { .stream-stat-summary-grid { grid-template-columns:repeat(2,1fr); } }
.stream-stat-mini-card { border:1px solid var(--line); border-radius:7px; padding:12px 14px; }
.stream-stat-mini-lbl { font-size:.72rem; font-weight:800; color:var(--muted); text-transform:uppercase; }
.stream-stat-mini-val { font-size:1.55rem; font-weight:900; margin:3px 0 1px; }
.recent-sessions-list { font-size:.83rem; }
.recent-session-row { padding:10px 0; border-bottom:1px solid var(--line); display:flex; flex-direction:column; gap:4px; }
.recent-session-row:last-child { border-bottom:none; }
.rsr-top { display:flex; align-items:center; gap:7px; min-width:0; }
.rsr-bot { display:flex; align-items:center; gap:10px; padding-left:2px; }
.rsr-ip { font-family:monospace; font-size:.79rem; color:var(--fg); font-weight:600; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.rsr-time { font-size:.75rem; color:var(--muted); white-space:nowrap; flex-shrink:0; }
.rsr-geo { font-size:.75rem; color:var(--muted); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.rsr-dur { font-size:.75rem; color:var(--muted); white-space:nowrap; flex-shrink:0; }
.browser-badge { font-size:.64rem; font-weight:800; padding:1px 6px; border-radius:999px; border:1px solid var(--line); white-space:nowrap; flex-shrink:0; }
.browser-badge.chrome { color:#34a853; background:rgba(52,168,83,.09); border-color:rgba(52,168,83,.3); }
.browser-badge.edge { color:var(--blue); background:rgba(78,126,232,.09); border-color:rgba(78,126,232,.3); }
.browser-badge.safari { color:var(--gold); background:rgba(243,179,61,.09); border-color:rgba(243,179,61,.3); }
.browser-badge.firefox { color:#ff7139; background:rgba(255,113,57,.09); border-color:rgba(255,113,57,.3); }
.browser-badge.opera { color:var(--rose); background:rgba(242,99,137,.09); border-color:rgba(242,99,137,.3); }
.browser-badge.other { color:var(--muted); background:rgba(100,116,139,.09); border-color:var(--line); }
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:.2} }
.live-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:currentColor; animation:pulse-dot 1.2s ease infinite; flex-shrink:0; }
.live-dot.offline { animation:none; opacity:.5; }
.live-badge { display:inline-flex; align-items:center; gap:6px; padding:3px 10px 3px 8px; border-radius:999px; font-size:.72rem; font-weight:800; letter-spacing:.06em; text-transform:uppercase; border:1px solid transparent; transition:color .25s,background .25s,border-color .25s; white-space:nowrap; }
.live-badge[data-state="live"] { color:var(--mint); background:rgba(52,211,153,.1); border-color:rgba(52,211,153,.28); }
.live-badge[data-state="error"] { color:var(--muted); background:rgba(100,116,139,.07); border-color:var(--line); }
.live-badge[data-state="connecting"] { color:var(--muted); background:rgba(100,116,139,.07); border-color:var(--line); }
.stream-group-header { padding:6px 4px 4px; font-size:.72rem; font-weight:900; color:var(--muted); text-transform:uppercase; letter-spacing:.06em; margin-top:10px; }
.stream-group-header:first-child { margin-top:0; }
.drag-handle { cursor:grab; color:var(--muted); font-size:1.1rem; padding:0 6px 0 2px; user-select:none; flex-shrink:0; opacity:.45; transition:opacity .15s; }
.drag-handle:hover { opacity:.9; }
.drag-handle:active { cursor:grabbing; }
.stream-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
.stream-row.dragging { opacity:.35; }
.modal-close-btn { background:none; border:none; color:var(--muted); font-size:1.1rem; cursor:pointer; padding:2px 6px; line-height:1; box-shadow:none; transform:none !important; }
.modal-close-btn:hover { color:var(--text); filter:none; }
.geo-section { border:1px solid var(--line); border-radius:8px; background:var(--panel); backdrop-filter:blur(14px); padding:20px 22px; margin-top:20px; }
.geo-section-head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; margin-bottom:14px; }
.geo-section-title { font-weight:900; }
.geo-map-canvas { width:100%; height:320px; }
.geo-country-list { display:flex; flex-direction:column; gap:5px; margin-top:12px; }
.geo-country-row { display:grid; grid-template-columns:1fr auto 110px; align-items:center; gap:8px; font-size:.82rem; }
.geo-bar-track { height:5px; background:rgba(100,116,139,.12); border-radius:999px; overflow:hidden; }
.geo-bar-fill { height:100%; border-radius:999px; background:var(--blue); opacity:.75; }
.sessions-pagination { display:flex; align-items:center; gap:8px; margin-top:10px; flex-wrap:wrap; }
.sessions-pagination .page-info { font-size:.82rem; color:var(--muted); font-weight:800; }
</style>
</head>
<body>
<div id="auth-loading" class="modal-overlay">
<div class="modal-card auth-card">
<h2 data-i18n="loading.auth">正在进入后台</h2>
<p class="hint" data-i18n="loading.auth_hint">正在验证登录状态...</p>
</div>
</div>
<div id="password-modal" class="modal-overlay hidden">
<div class="modal-card">
<div class="modal-head">
<h2 data-i18n="login.title">管理员登录</h2>
<button class="theme-toggle" type="button" aria-label="Toggle dark mode" title="Toggle dark mode">🌙</button>
</div>
<form id="password-form">
<input type="password" id="password-input" placeholder="请输入密码" data-i18n="login.placeholder" required>
<button type="submit" class="btn-primary" style="width: 100%; margin-top: 14px;" data-i18n="login.submit">登 录</button>
<p id="password-error" class="status error hidden" data-i18n="login.error">密码错误!</p>
</form>
</div>
</div>
<div id="admin-panel" class="hidden page-shell">
<nav class="site-nav" aria-label="Admin navigation">
<a class="site-brand" id="admin-site-brand" href="/">StreamHall</a>
<div class="site-actions">
<a class="button-link btn-secondary" href="/" data-i18n="nav.back">返回首页</a>
<button id="logout-btn" class="btn-secondary" data-i18n="nav.logout">退出登录</button>
<button id="lang-toggle" class="theme-toggle" type="button" style="font-size:.82rem;font-weight:900;width:auto;padding:0 10px;" aria-label="切换语言" title="切换语言">EN</button>
<button class="theme-toggle" type="button" aria-label="Toggle dark mode" title="Toggle dark mode">🌙</button>
</div>
</nav>
<header class="hero-panel">
<h1 data-i18n="hero.title">直播管理</h1>
<p id="admin-hero-desc">维护 StreamHall 资料库的直播入口、播放源和访问控制。</p>
</header>
<div class="admin-layout">
<aside class="admin-menu" aria-label="Admin sidebar menu">
<button type="button" class="admin-menu-btn active" data-admin-view-target="dashboard" data-i18n="menu.dashboard">数据看板</button>
<div class="admin-menu-group open" id="records-group">
<button type="button" class="admin-menu-group-btn" id="records-group-toggle"><span data-i18n="menu.streams">直播列表</span> <span class="group-arrow"></span></button>
<div class="admin-sub-menu">
<button type="button" class="admin-sub-btn" data-stream-label="live">LIVE</button>
<button type="button" class="admin-sub-btn" data-stream-label="archive">ARCHIVE</button>
</div>
</div>
<button type="button" class="admin-menu-btn" data-admin-view-target="obs" data-i18n="menu.obs">直播推流</button>
<button type="button" class="admin-menu-btn" data-admin-view-target="telegram" data-i18n="menu.telegram">TG 推送</button>
<button type="button" class="admin-menu-btn" data-admin-view-target="site" data-i18n="menu.site">网站设置</button>
</aside>
<main class="admin-content">
<div class="admin-section admin-view" data-admin-view="site">
<p class="section-kicker" data-i18n="kicker.site_settings">Site Settings</p>
<h2 data-i18n="section.site_settings">网站设置</h2>
<form id="site-settings-form">
<div class="form-grid">
<div>
<label data-i18n="form.site_title">网站标题</label>
<input type="text" id="site-title-input" maxlength="80" required>
</div>
<div>
<label data-i18n="form.site_desc">网站简介</label>
<textarea id="site-description-input" rows="3" maxlength="300" data-i18n-placeholder="ph.site_desc"></textarea>
</div>
<div>
<label data-i18n="form.site_icon">网站 Icon URL</label>
<input type="text" id="site-icon-url" maxlength="500" placeholder="/favicon.ico 或 https://example.com/favicon.png" data-i18n="ph.site_icon">
</div>
<div>
<label data-i18n="form.site_url">网站公开访问地址</label>
<input type="url" id="site-public-base-url" placeholder="自动使用当前后台地址" data-i18n="ph.site_url">
</div>
</div>
<div style="margin-top: 18px;">
<label data-i18n="form.nav_links">首页菜单栏链接</label>
<div id="site-nav-links-container"></div>
<button type="button" id="site-add-nav-link" class="btn-success" data-i18n="btn.add_nav_link">+ 添加菜单链接</button>
<p class="hint" data-i18n="hint.nav_links">留空则不显示菜单链接。支持站内锚点如 #stream-list,或完整 http/https 链接。</p>
</div>
<div style="margin-top: 18px;">
<label data-i18n="form.footer">页脚内容 Markdown</label>
<textarea id="site-footer-markdown" rows="8" maxlength="5000" placeholder="支持标题、段落、列表和链接。留空则首页不显示页脚内容卡片。" data-i18n="ph.footer"></textarea>
<p class="hint" data-i18n="hint.footer_example">示例:## 联系方式&#10;- [项目主页](https://example.com)</p>
</div>
<div class="actions">
<button type="submit" class="btn-primary" data-i18n="btn.save_site">保存网站设置</button>
</div>
<p id="site-settings-status" class="status hidden"></p>
</form>
<div class="security-panel">
<h3 data-i18n="h3.security">安全设置</h3>
<p class="hint" data-i18n="hint.pw_change">修改后台登录密码后,当前会话会自动退出。</p>
<form id="admin-password-form">
<div class="form-grid">
<div>
<label data-i18n="form.current_pw">当前密码</label>
<input type="password" id="admin-current-password" autocomplete="current-password" required>
</div>
<div>
<label data-i18n="form.new_pw">新密码</label>
<input type="password" id="admin-new-password" autocomplete="new-password" minlength="10" required>
</div>
<div>
<label data-i18n="form.confirm_pw">确认新密码</label>
<input type="password" id="admin-confirm-password" autocomplete="new-password" minlength="10" required>
</div>
</div>
<div class="actions">
<button type="submit" class="btn-warning" data-i18n="btn.update_pw">更新后台密码</button>
</div>
<p id="admin-password-status" class="status hidden"></p>
</form>
</div>
<div class="security-panel" id="api-keys-panel">
<h3 data-i18n="h3.api_keys">API 密钥</h3>
<p class="hint" data-i18n="hint.api_keys">用于程序化访问管理接口。请求时携带 <code>Authorization: Bearer &lt;token&gt;</code> 请求头或 <code>?api_key=&lt;token&gt;</code> 参数。密钥只在创建时显示一次。</p>
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:flex-end;">
<div style="flex:1;">
<label data-i18n="form.api_key_label">密钥备注</label>
<input type="text" id="api-key-label-input" maxlength="80" data-i18n-placeholder="ph.api_key_label">
</div>
<button type="button" class="btn-primary" id="api-key-create-btn" data-i18n="btn.create_api_key">生成密钥</button>
</div>
<div id="api-key-new-token" class="hidden" style="background:var(--panel);border:1.5px solid var(--mint);border-radius:7px;padding:12px 14px;margin-bottom:12px;">
<div style="font-size:.78rem;color:var(--mint);font-weight:800;margin-bottom:6px;" data-i18n="api.new_token_hint">请复制并妥善保管——密钥不会再次显示</div>
<div style="display:flex;gap:8px;align-items:center;">
<code id="api-key-new-token-value" style="flex:1;word-break:break-all;font-size:.82rem;"></code>
<button type="button" id="api-key-copy-btn" class="btn-primary" style="white-space:nowrap;" data-i18n="api.copy">复制</button>
</div>
</div>
<div id="api-keys-list"></div>
<p id="api-keys-status" class="status hidden"></p>
</div>
</div>
<div class="admin-section admin-view" data-admin-view="telegram">
<p class="section-kicker" data-i18n="kicker.telegram">Telegram Bot</p>
<h2 data-i18n="section.telegram">TG Bot 推送</h2>
<p class="hint" data-i18n="hint.tg_config">配置 Bot Token 和接收方 ID 后,可在检测到开播或关播时自动发送消息。</p>
<form id="telegram-settings-form">
<div class="form-grid">
<div>
<label>Bot Token</label>
<input type="password" id="telegram-bot-token" autocomplete="off" placeholder="123456:ABC-DEF...">
</div>
<div>
<label data-i18n="form.tg_chat_id">群组或用户 ID</label>
<input type="text" id="telegram-chat-id" placeholder="-1001234567890">
</div>
</div>
<div style="display:flex;flex-direction:column;gap:20px;margin-top:20px;">
<div>
<div style="font-weight:900;font-size:.88rem;margin-bottom:10px;display:flex;align-items:center;gap:6px;">🔴 <span data-i18n="tg.live_section">直播</span> <span data-i18n="tg.live_label" style="font-size:.75rem;color:var(--muted);font-weight:700;">LIVE</span></div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="border:1px solid var(--line);border-radius:8px;padding:16px 18px;">
<label class="check-line" style="margin-bottom:10px;">
<input type="checkbox" id="telegram-live-notify-start">
<span style="font-weight:700;" data-i18n="tg.live_start">开播时推送</span>
</label>
<label style="font-size:.8rem;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:4px;" data-i18n="tg.template">消息模板</label>
<textarea id="telegram-live-start-template" rows="3"></textarea>
</div>
<div style="border:1px solid var(--line);border-radius:8px;padding:16px 18px;">
<label class="check-line" style="margin-bottom:10px;">
<input type="checkbox" id="telegram-live-notify-stop">
<span style="font-weight:700;" data-i18n="tg.live_stop">关播时推送</span>
</label>
<label style="font-size:.8rem;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:4px;" data-i18n="tg.template">消息模板</label>
<textarea id="telegram-live-stop-template" rows="3"></textarea>
</div>
</div>
</div>
<div>
<div style="font-weight:900;font-size:.88rem;margin-bottom:10px;display:flex;align-items:center;gap:6px;">🗂️ <span data-i18n="tg.archive_section">存档</span> <span data-i18n="tg.archive_label" style="font-size:.75rem;color:var(--muted);font-weight:700;">ARCHIVE</span></div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="border:1px solid var(--line);border-radius:8px;padding:16px 18px;">
<label class="check-line" style="margin-bottom:10px;">
<input type="checkbox" id="telegram-archive-notify-start">
<span style="font-weight:700;" data-i18n="tg.archive_start">上架时推送</span>
</label>
<label style="font-size:.8rem;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:4px;" data-i18n="tg.template">消息模板</label>
<textarea id="telegram-archive-start-template" rows="3"></textarea>
</div>
<div style="border:1px solid var(--line);border-radius:8px;padding:16px 18px;">
<label class="check-line" style="margin-bottom:10px;">
<input type="checkbox" id="telegram-archive-notify-stop">
<span style="font-weight:700;" data-i18n="tg.archive_stop">下架时推送</span>
</label>
<label style="font-size:.8rem;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:4px;" data-i18n="tg.template">消息模板</label>
<textarea id="telegram-archive-stop-template" rows="3"></textarea>
</div>
</div>
</div>
</div>
<p class="hint" style="margin-top:10px;" data-i18n="hint.tg_vars">可用变量:{title}、{url}、{site_title}、{time}、{status}、{stream_id}、{public_id}、{link_name}、{source_url} &nbsp;·&nbsp; 支持 HTML</p>
<div class="actions">
<button type="submit" class="btn-primary" data-i18n="btn.save_tg">保存 TG 设置</button>
<button type="button" id="telegram-test-btn" class="btn-secondary" data-i18n="btn.tg_test">发送测试消息</button>
</div>
<p id="telegram-status" class="status hidden"></p>
</form>
</div>
<div class="admin-section admin-view" data-admin-view="obs">
<p class="section-kicker" data-i18n="kicker.obs">Stream Setup</p>
<h2 data-i18n="section.obs">推流配置</h2>
<p class="hint" data-i18n="hint.obs_rtmp">推流端使用 RTMP 推流到 NAS,播放器使用 SRS 输出的 HLS 或 FLV 地址。</p>
<div class="form-grid">
<div>
<label data-i18n="form.obs_key">串流密钥</label>
<input type="text" id="obs-stream-key" value="livestream">
</div>
<div>
<label data-i18n="form.obs_host">RTMP 推流主机/IP</label>
<input type="text" id="obs-rtmp-host" placeholder="例如 live-rtmp.stdm.moe 或 live-rtmp.stdm.moe:1935" data-i18n="ph.obs_host">
</div>
<div>
<label data-i18n="form.obs_playback">HLS/FLV 公开访问地址</label>
<input type="url" id="obs-playback-origin" placeholder="例如 https://live.example.com" data-i18n="ph.obs_playback">
</div>
<div>
<label data-i18n="form.obs_server">RTMP 服务器</label>
<div class="obs-copy-row">
<input type="text" id="obs-server-url" readonly onclick="this.select()">
<button type="button" class="btn-secondary obs-copy-btn" data-copy-target="obs-server-url" data-i18n="btn.copy">复制</button>
</div>
</div>
</div>
<div class="obs-grid">
<div>
<label data-i18n="form.obs_key_disp">串流密钥</label>
<div class="obs-copy-row">
<input type="text" id="obs-stream-key-display" readonly onclick="this.select()">
<button type="button" class="btn-secondary obs-copy-btn" data-copy-target="obs-stream-key-display" data-i18n="btn.copy">复制</button>
</div>
</div>
<div>
<label data-i18n="form.obs_hls">HLS 播放地址</label>
<div class="obs-copy-row">
<input type="text" id="obs-hls-url" readonly onclick="this.select()">
<button type="button" class="btn-secondary obs-copy-btn" data-copy-target="obs-hls-url" data-i18n="btn.copy">复制</button>
</div>
</div>
<div>
<label data-i18n="form.obs_flv">FLV 播放地址</label>
<div class="obs-copy-row">
<input type="text" id="obs-flv-url" readonly onclick="this.select()">
<button type="button" class="btn-secondary obs-copy-btn" data-copy-target="obs-flv-url" data-i18n="btn.copy">复制</button>
</div>
</div>
</div>
<div class="actions">
<button type="button" id="use-obs-hls-btn" class="btn-success" data-i18n="btn.use_obs_hls">填入 HLS 到直播表单</button>
<button type="button" id="use-obs-flv-btn" class="btn-secondary" data-i18n="btn.use_obs_flv">填入 FLV 到直播表单</button>
</div>
<div class="obs-route-panel">
<h3 data-i18n="h3.obs_routes">隐藏播放地址映射</h3>
<p class="hint" data-i18n="hint.obs_routes">新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。</p>
<div class="obs-route-form">
<div>
<label data-i18n="form.obs_route_key">新增推流码</label>
<input type="text" id="obs-route-key" placeholder="例如 my-live-001" data-i18n="ph.obs_route_key">
</div>
<button type="button" id="obs-route-add-btn" class="btn-primary" data-i18n="btn.gen_route">生成映射</button>
</div>
<div id="obs-route-list" class="obs-route-list"></div>
</div>
<p id="obs-status" class="status hidden"></p>
</div>
<div class="admin-section admin-view" data-admin-view="records">
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-bottom:18px;">
<div>
<p class="section-kicker" data-i18n="kicker.stream_records">Stream Records</p>
<h2 style="margin:0;" data-i18n="section.stream_records">已录入的直播</h2>
</div>
<button type="button" id="add-stream-btn" class="btn-primary" style="white-space:nowrap;flex-shrink:0;" data-i18n="btn.add_stream">+ 添加</button>
</div>
<div class="stream-list-filters" aria-label="Stream list filters">
<input type="search" id="admin-stream-search" placeholder="搜索直播名 / ID / 源地址" data-i18n="ph.search">
<select id="admin-stream-filter">
<option value="all" data-i18n="filter.all">无筛选</option>
<option value="enabled" data-i18n="filter.enabled">已开启</option>
<option value="disabled" data-i18n="filter.disabled">已关闭</option>
<option value="public" data-i18n="filter.public">公开</option>
<option value="password" data-i18n="filter.password">密码</option>
<option value="hidden" data-i18n="filter.hidden">隐藏</option>
<option value="telegram" data-i18n="filter.telegram">TG 推送</option>
<option value="key">KeyOverride</option>
</select>
<select id="admin-stream-sort">
<option value="default" data-i18n="sort.default">默认排序</option>
<option value="created_desc" data-i18n="sort.newest">最新创建</option>
<option value="updated_desc" data-i18n="sort.updated">最近更新</option>
<option value="name_asc" data-i18n="sort.name">名称 A-Z</option>
<option value="links_desc" data-i18n="sort.links">源数量多到少</option>
</select>
<button type="button" id="admin-stream-reset" class="btn-secondary" data-i18n="btn.reset_filter">清空</button>
</div>
<div class="stream-list-toolbar" aria-label="Stream list pagination">
<div class="page-size-control">
<span id="admin-stream-count" class="stream-list-count">共 0 条</span>
<label for="admin-page-size" data-i18n="streams.per_page">每页</label>
<select id="admin-page-size">
<option value="5" data-i18n="pgsz.5">5 条</option>
<option value="10" data-i18n="pgsz.10">10 条</option>
<option value="20" data-i18n="pgsz.20">20 条</option>
<option value="30" data-i18n="pgsz.30">30 条</option>
<option value="40" data-i18n="pgsz.40">40 条</option>
<option value="50" data-i18n="pgsz.50">50 条</option>
</select>
</div>
<div class="pagination-control">
<button type="button" id="admin-prev-page" class="btn-secondary" data-i18n="page.prev">上一页</button>
<span id="admin-page-info" class="page-info">第 1 / 1 页</span>
<button type="button" id="admin-next-page" class="btn-secondary" data-i18n="page.next">下一页</button>
</div>
</div>
<div id="admin-stream-list"></div>
</div>
<div class="admin-section admin-view active" data-admin-view="dashboard">
<p class="section-kicker" data-i18n="kicker.analytics">Analytics</p>
<h2 style="margin-top:0;margin-bottom:18px;" data-i18n="section.analytics">数据看板</h2>
<div class="dash-toolbar">
<span id="dash-live-indicator" class="live-badge" data-state="connecting">连接中...</span>
<button type="button" id="dash-export-btn" class="btn-success" data-i18n="dash.export">⬇ 导出 CSV</button>
</div>
<div class="dash-card-grid">
<div class="dash-card dash-accent-mint">
<div class="dash-card-label" data-i18n="dash.online">当前在线</div>
<div class="dash-card-value" id="dash-online"></div>
<div class="dash-card-sub" data-i18n="dash.online_sub">45s 内活跃</div>
</div>
<div class="dash-card dash-accent-blue">
<div class="dash-card-label" data-i18n="dash.today">今日观看</div>
<div class="dash-card-value" id="dash-today"></div>
<div class="dash-card-sub" data-i18n="dash.today_sub">今日新增会话</div>
</div>
<div class="dash-card dash-accent-rose">
<div class="dash-card-label" data-i18n="dash.total_hist">历史总量</div>
<div class="dash-card-value" id="dash-total"></div>
<div class="dash-card-sub" data-i18n="dash.total_sub">所有观看会话</div>
</div>
<div class="dash-card dash-accent-gold">
<div class="dash-card-label" data-i18n="dash.unique">独立访客</div>
<div class="dash-card-value" id="dash-unique"></div>
<div class="dash-card-sub" data-i18n="dash.unique_sub">去重 Visitor ID</div>
</div>
</div>
<div class="dash-two-col">
<div class="admin-section" style="margin-bottom:0;padding:18px 20px;">
<p class="section-kicker" style="margin-bottom:4px;" data-i18n="kicker.device">Device</p>
<div style="font-weight:900;margin-bottom:10px;" data-i18n="section.device">设备分布</div>
<div class="dash-hbar-list">
<div class="dash-hbar-row"><span class="dash-hbar-icon">💻</span><span class="dash-hbar-lbl" data-i18n="device.desktop">桌面端</span><div class="dash-hbar-track"><div class="dash-hbar-fill" id="dh-desktop" style="width:0%;background:var(--blue)"></div></div><span class="dash-hbar-pct" id="dh-desktop-pct"></span></div>
<div class="dash-hbar-row"><span class="dash-hbar-icon">📱</span><span class="dash-hbar-lbl" data-i18n="device.mobile">移动端</span><div class="dash-hbar-track"><div class="dash-hbar-fill" id="dh-mobile" style="width:0%;background:var(--mint)"></div></div><span class="dash-hbar-pct" id="dh-mobile-pct"></span></div>
<div class="dash-hbar-row"><span class="dash-hbar-icon">📋</span><span class="dash-hbar-lbl" data-i18n="device.tablet">平板端</span><div class="dash-hbar-track"><div class="dash-hbar-fill" id="dh-tablet" style="width:0%;background:var(--gold)"></div></div><span class="dash-hbar-pct" id="dh-tablet-pct"></span></div>
</div>
</div>
<div class="admin-section" style="margin-bottom:0;padding:18px 20px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:10px;">
<div>
<p class="section-kicker" style="margin-bottom:2px;" data-i18n="kicker.timeseries">Timeseries</p>
<div style="font-weight:900;" id="dash-chart-title" data-i18n="section.timeseries">时间分布</div>
</div>
<div class="dash-range-tabs" id="dash-range-tabs">
<button type="button" class="dash-range-btn active" data-range="today" data-i18n="range.today">今日</button>
<button type="button" class="dash-range-btn" data-range="7d" data-i18n="range.7d">近 7 天</button>
<button type="button" class="dash-range-btn" data-range="30d" data-i18n="range.30d">近 30 天</button>
</div>
</div>
<div class="dash-bar-chart" id="dash-bar-chart"></div>
<div class="dash-bar-axis" id="dash-bar-axis"></div>
</div>
</div>
<div class="dash-two-col" style="margin-top:14px;">
<div class="admin-section" style="margin-bottom:0;padding:18px 20px;">
<p class="section-kicker" style="margin-bottom:4px;" data-i18n="kicker.browser">Browser</p>
<div style="font-weight:900;margin-bottom:10px;" data-i18n="section.browser">浏览器分布</div>
<div class="dash-hbar-list" id="dash-browser-list"><div style="color:var(--muted);font-size:.82rem;" data-i18n="status.loading">加载中...</div></div>
</div>
<div class="admin-section" style="margin-bottom:0;padding:18px 20px;">
<p class="section-kicker" style="margin-bottom:4px;" data-i18n="kicker.os">OS</p>
<div style="font-weight:900;margin-bottom:10px;" data-i18n="section.os">操作系统分布</div>
<div class="dash-hbar-list" id="dash-os-list"><div style="color:var(--muted);font-size:.82rem;" data-i18n="status.loading">加载中...</div></div>
</div>
</div>
<div style="border:1px solid var(--line);border-radius:8px;background:var(--panel);backdrop-filter:blur(14px);padding:20px 22px;">
<div style="font-weight:900;margin-bottom:14px;" data-i18n="dash.stream_detail">直播统计明细</div>
<div style="overflow-x:auto;">
<table class="dash-table">
<thead><tr>
<th data-sort-col="event_name" data-i18n="table.stream_name">直播名称</th>
<th style="text-align:right" data-sort-col="online" data-i18n="table.online">在线</th>
<th style="text-align:right" data-sort-col="today_views" data-i18n="table.today">今日</th>
<th data-sort-col="total_views" data-i18n="table.total">总计</th>
<th style="text-align:right" data-sort-col="unique_visitors" data-i18n="table.unique">独立访客</th>
<th style="text-align:right" data-sort-col="avg_duration" data-i18n="table.avg_dur">均时长</th>
<th data-i18n="table.device">设备</th>
<th style="text-align:right" data-sort-col="last_seen_at" data-i18n="table.last_active">最后活跃</th>
</tr></thead>
<tbody id="dash-streams-tbody">
<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;" data-i18n="table.empty">点击「数据看板」后加载</td></tr>
</tbody>
</table>
</div>
</div>
<div class="geo-section">
<div class="geo-section-head">
<div class="geo-section-title" data-i18n="geo.title">地理分布</div>
<div class="dash-range-tabs" id="dash-geo-range">
<button class="dash-range-btn" data-range="today" data-i18n="range.today">今日</button>
<button class="dash-range-btn active" data-range="30d" data-i18n="range.30d">近 30 天</button>
<button class="dash-range-btn" data-range="all" data-i18n="range.all">全部</button>
</div>
</div>
<div class="geo-map-canvas" id="dash-geo-map"></div>
<div class="geo-country-list" id="dash-geo-list"></div>
</div>
</div>
</main>
</div>
</div>
<script>
(() => {
const savedTheme = localStorage.getItem('site_theme');
if (savedTheme) document.documentElement.dataset.theme = savedTheme;
})();
// ── i18n ──────────────────────────────────────────────────────────
var TRANSLATIONS = {
zh: {
// section-kickers: zh shows English (existing design), en shows Chinese (mirrored)
'kicker.site_settings': 'Site Settings',
'kicker.telegram': 'Telegram Bot',
'kicker.obs': 'Stream Setup',
'kicker.stream_records':'Stream Records',
'kicker.analytics': 'Analytics',
'kicker.device': 'Device',
'kicker.timeseries': 'Timeseries',
'kicker.browser': 'Browser',
'kicker.os': 'OS',
// section headings
'section.site_settings': '网站设置',
'section.telegram': 'TG Bot 推送',
'section.obs': '推流配置',
'section.stream_records':'已录入的直播',
'section.analytics': '数据看板',
'section.device': '设备分布',
'section.timeseries': '时间分布',
'section.browser': '浏览器分布',
'section.os': '操作系统分布',
// nav
'nav.back': '返回首页',
'nav.logout': '退出登录',
'nav.default_label': '直播列表',
// hero
'hero.title': '直播管理',
// admin page
'admin.title_suffix': '直播管理后台',
'admin.hero_desc': '维护 {site} 资料库的直播入口、播放源和访问控制。',
// menu
'menu.dashboard': '数据看板',
'menu.streams': '直播列表',
'menu.obs': '推流配置',
'menu.telegram': 'TG 推送',
'menu.site': '网站设置',
// loading / login
'loading.auth': '正在进入后台',
'loading.auth_hint': '正在验证登录状态...',
'login.title': '管理员登录',
'login.placeholder': '请输入密码',
'login.submit': '登 录',
'login.error': '密码错误!',
// dashboard cards
'dash.online': '当前在线',
'dash.online_sub': '45s 内活跃',
'dash.today': '今日观看',
'dash.today_sub': '今日新增会话',
'dash.total_hist': '历史总量',
'dash.total_sub': '所有观看会话',
'dash.unique': '独立访客',
'dash.unique_sub': '去重 Visitor ID',
'dash.export': '⬇ 导出 CSV',
'dash.exporting': '导出中...',
'dash.stream_detail': '直播统计明细',
// device labels
'device.desktop': '桌面端',
'device.mobile': '移动端',
'device.tablet': '平板端',
// range buttons
'range.today': '今日',
'range.7d': '近 7 天',
'range.30d': '近 30 天',
'range.all': '全部',
'range.7d_short': '7天',
'range.30d_short': '30天',
// chart titles (JS-rendered)
'chart.today': '今日观看趋势(每小时)',
'chart.7d': '近 7 天观看趋势',
'chart.30d': '近 30 天观看趋势',
'chart.unit': '次',
// table headers
'table.stream_name': '直播名称',
'table.online': '在线',
'table.today': '今日',
'table.total': '总计',
'table.unique': '独立访客',
'table.avg_dur': '均时长',
'table.device': '设备',
'table.last_active': '最后活跃',
'table.no_data': '暂无观看数据',
'table.empty': '点击「数据看板」后加载',
'table.deleted': '已删除',
// geo
'geo.title': '地理分布',
'geo.no_data': '暂无地理数据',
// stat modal
'stat.connecting': '连接中...',
'stat.loading': '加载中...',
'stat.no_records': '暂无记录',
'stat.online': '当前在线',
'stat.today': '今日观看',
'stat.total': '历史总量',
'stat.unique': '独立访客',
'stat.device': '设备分布',
'stat.timeseries': '时间分布',
'stat.browser': '浏览器分布',
'stat.os': '操作系统分布',
'stat.geo': '地理分布',
'stat.sessions': '最近会话',
'stat.title': '直播统计',
// pagination
'page.prev': '上一页',
'page.next': '下一页',
'page.page_of': '第 {cur} / {total} 页',
'page.total': '共 {n} 条',
// session sort
'sort.last_seen': '最后活跃',
'sort.started_at': '开始时间',
'sort.duration': '时长',
'sort.device': '设备',
'sort.browser': '浏览器',
'sort.ip': 'IP',
// stream list
'streams.per_page': '每页',
'streams.showing': '显示 {shown} / 共 {total} 条',
'streams.no_match': '没有匹配的直播',
// live indicator
'live.live': '实时',
'live.disconnected':'已断开',
// hbar empty
'hbar.no_data': '暂无数据',
// session
'sess.watching': '观看中',
'sess.dur': '时长',
'sess.online': '在线',
// editor
'editor.add_title': '新增直播',
'editor.edit_title':'编辑直播',
// lang
'lang.btn_label': 'EN',
// actions
'action.disable': '停用',
'action.enable': '启用',
// status
'status.loading': '加载中...',
// relative time
'rel.just_now': '刚刚',
'rel.min_ago': '{n}min 前',
'rel.hour_ago': '{n}h 前',
'rel.day_ago': '{n}天前',
// session detail
'sess.lt1min': '< 1分钟',
// stream list live stats
'ss.live_stat': '在线 {a} · 今日 {b} · 总 {c}',
// stream action buttons
'action.stats': '统计',
'action.copy_link': '复制链接',
'action.delete': '删除',
'action.push': '推送',
'action.push_on': '关闭推送',
'action.push_off': '加入推送',
'action.more': '更多',
// form labels
'form.site_title': '网站标题',
'form.site_desc': '网站简介',
'form.site_icon': '网站 Icon URL',
'form.site_url': '网站公开访问地址',
'form.nav_links': '首页菜单栏链接',
'form.footer': '页脚内容 Markdown',
'form.current_pw': '当前密码',
'form.new_pw': '新密码',
'form.confirm_pw': '确认新密码',
'form.tg_chat_id': '群组或用户 ID',
'form.obs_key': '串流密钥',
'form.obs_host': 'RTMP 推流主机/IP',
'form.obs_playback':'HLS/FLV 公开访问地址',
'form.obs_server': 'RTMP 服务器',
'form.obs_key_disp':'串流密钥',
'form.obs_hls': 'HLS 播放地址',
'form.obs_flv': 'FLV 播放地址',
'form.obs_route_key':'新增推流码',
'form.stream_name': '直播名称',
'form.stream_pw': '访问密码 (留空则公开)',
'form.stream_type': '直播类型',
'form.hide_home': '不在主页显示',
// section headings
'h3.security': '安全设置',
'h3.api_keys': 'API 密钥',
'hint.api_keys': '用于程序化访问管理接口。请求时携带 Authorization: Bearer <token> 请求头或 ?api_key=<token> 参数。密钥只在创建时显示一次。',
'form.api_key_label': '密钥备注',
'ph.api_key_label': '备注(可选)',
'btn.create_api_key': '生成密钥',
'api.new_token_hint': '请复制并妥善保管——密钥不会再次显示',
'api.copy': '复制',
'api.copied': '已复制',
'api.revoke': '撤销',
'api.never_used': '从未使用',
'api.last_used': '最后使用:',
'api.no_keys': '暂无 API 密钥',
'api.confirm_revoke': '确定要撤销这个密钥吗?撤销后无法恢复。',
'h3.obs_routes': '隐藏播放地址映射',
'h3.links': '视角配置',
// buttons
'btn.add_nav_link': '+ 添加菜单链接',
'btn.save_site': '保存网站设置',
'btn.update_pw': '更新后台密码',
'btn.save_tg': '保存 TG 设置',
'btn.tg_test': '发送测试消息',
'btn.copy': '复制',
'btn.use_obs_hls': '填入 HLS 到直播表单',
'btn.use_obs_flv': '填入 FLV 到直播表单',
'btn.gen_route': '生成映射',
'btn.add_stream': '+ 添加',
'btn.reset_filter': '清空',
'btn.add_link': '+ 添加视角',
'btn.save': '保 存',
'btn.cancel': '取 消',
'btn.delete': '删除',
'btn.copy_hls': '复制 HLS',
'btn.copy_flv': '复制 FLV',
'btn.use_hls': '填入 HLS',
'btn.use_flv': '填入 FLV',
'btn.open': '打开',
'btn.edit': '编辑',
// placeholders
'ph.site_desc': '中文简介(可留空)',
'ph.site_icon': '/favicon.ico 或 https://example.com/favicon.png',
'ph.site_url': '自动使用当前后台地址',
'ph.footer': '支持标题、段落、列表和链接。留空则首页不显示页脚内容卡片。',
'ph.obs_host': '例如 live-rtmp.stdm.moe 或 live-rtmp.stdm.moe:1935',
'ph.obs_playback': '例如 https://live.example.com',
'ph.obs_route_key': '例如 my-live-001',
'ph.search': '搜索直播名 / ID / 源地址',
'ph.nav_label': '菜单名',
'ph.nav_url': '#stream-list 或 https://example.com',
'ph.link_name': '视角名',
'ph.link_url': '链接 (m3u8/flv/mpd)',
'ph.key_aes': 'AES-128 Key Hex,可多行: main-video=hex',
'ph.clearkey': 'ClearKey 信息,如 {"kid":"key"}',
// hints
'hint.nav_links': '留空则不显示菜单链接。支持站内锚点如 #stream-list,或完整 http/https 链接。',
'hint.pw_change': '修改后台登录密码后,当前会话会自动退出。',
'hint.tg_config': '配置 Bot Token 和接收方 ID 后,可在检测到开播或关播时自动发送消息。',
'hint.tg_vars': '可用变量:{title}、{url}、{site_title}、{time}、{status}、{stream_id}、{public_id}、{link_name}、{source_url} &nbsp;·&nbsp; 支持 HTML',
'hint.obs_rtmp': '推流端使用 RTMP 推流到 NAS,播放器使用 SRS 输出的 HLS 或 FLV 地址。',
'hint.obs_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。',
'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息',
'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)',
// TG
'tg.live_section': '直播', 'tg.live_label': 'LIVE',
'tg.archive_section':'存档', 'tg.archive_label': 'ARCHIVE',
'tg.live_start': '开播时推送',
'tg.live_stop': '关播时推送',
'tg.archive_start': '上架时推送',
'tg.archive_stop': '下架时推送',
'tg.template': '消息模板',
// filter/sort options
'filter.all': '无筛选',
'filter.enabled': '已开启',
'filter.disabled': '已关闭',
'filter.public': '公开',
'filter.password': '密码',
'filter.hidden': '隐藏',
'filter.telegram': 'TG 推送',
'sort.default': '默认排序',
'sort.newest': '最新创建',
'sort.updated': '最近更新',
'sort.name': '名称 A-Z',
'sort.links': '源数量多到少',
'pgsz.5': '5 条', 'pgsz.10': '10 条', 'pgsz.20': '20 条',
'pgsz.30': '30 条','pgsz.40': '40 条', 'pgsz.50': '50 条', 'pgsz.100': '100 条',
// stream badges
'badge.hidden': '隐',
'badge.closed': '关',
'badge.tg': '推',
// stream meta
'meta.links': '个源',
'meta.id': '内部 ID',
'meta.pub_id': '播放 ID',
// probe status
'probe.no_info': '没有监测到推流信息',
'probe.detecting': '正在检测推流信息...',
'probe.detected': '已检测到推流信息',
'probe.waiting': '等待自动检测...',
'probe.closed': '直播已关闭',
// status messages
'msg.site_saved': '网站设置已保存',
'msg.site_err': '加载网站设置失败',
'msg.pw_min': '新密码至少需要 10 位',
'msg.pw_mismatch': '两次输入的新密码不一致',
'msg.pw_saved': '密码已更新,请重新登录',
'msg.tg_saved': 'TG 设置已保存',
'msg.tg_err': '加载 TG 设置失败',
'msg.tg_test': '测试消息已发送',
'msg.no_routes': '还没有推流码映射。',
'msg.obs_err': '加载推流码映射失败',
'msg.route_added': '推流码映射已生成',
'msg.route_deleted':'推流码映射已删除',
'msg.copied': '已复制',
'msg.no_key': '请先填写推流码',
'msg.sort_err': '排序保存失败',
// confirm/alert
'confirm.delete': '确定删除?',
'confirm.delete_route': '确定删除这个推流码映射?',
'alert.link_copied': '链接已复制!',
// theme
'theme.to_dark': '切换暗黑模式',
'theme.to_light': '切换明亮模式',
// type selector
'type.auto': '自动',
// OBS link names
'obs.local_hls': '本地 HLS',
'obs.local_flv': '本地 FLV',
'obs.hidden_hls': '隐藏 HLS',
'obs.hidden_flv': '隐藏 FLV',
'obs.default_name': '本地推流',
'obs.filled_in': '{name} 已填入直播表单',
// API error codes from server
'err.stream_not_found': '直播不存在',
'err.stream_disabled': '直播已关闭',
'err.stream_not_found_or_disabled':'直播不存在或已关闭',
'err.stream_name_empty': '直播名称不能为空',
'err.stream_key_empty': '推流密钥不能为空',
'err.stream_key_too_long': '推流密钥过长',
'err.missing_stream_id': '缺少直播 ID',
'err.auth_incorrect_password': '密码错误',
'err.auth_current_pw_wrong': '当前密码不正确',
'err.auth_pw_too_short': '新密码至少需要 10 个字符',
'err.auth_pw_mismatch': '两次密码不一致',
'err.auth_required': '未登录或会话已过期',
'err.missing_session_id': '缺少会话 ID',
'err.viewer_session_not_found': '观看会话不存在',
'err.site_title_empty': '网站标题不能为空',
'err.site_icon_invalid': '网站图标 URL 格式无效',
'err.tg_config_missing': '请填写 Telegram Bot Token 和 Chat ID',
'err.tg_api_error': 'Telegram 接口错误',
'err.tg_api_invalid': 'Telegram 接口返回无效响应',
'err.obs_ids_required': '需要提供推流码 ID 和标签',
'err.missing_route_id': '缺少路由 ID',
'err.route_not_found': '路由不存在',
'err.missing_id': '缺少 ID',
'err.invalid_action': '无效操作',
'err.server_error': '服务器内部错误',
},
en: {
// kickers: en shows Chinese label (mirrored bilingual)
'kicker.site_settings': '网站设置',
'kicker.telegram': 'TG Bot 推送',
'kicker.obs': '推流配置',
'kicker.stream_records':'已录入的直播',
'kicker.analytics': '数据看板',
'kicker.device': '设备分布',
'kicker.timeseries': '时间分布',
'kicker.browser': '浏览器分布',
'kicker.os': '操作系统',
// section headings
'section.site_settings': 'Site Settings',
'section.telegram': 'Telegram Bot',
'section.obs': 'Stream Setup',
'section.stream_records':'Stream Records',
'section.analytics': 'Analytics',
'section.device': 'Device Distribution',
'section.timeseries': 'Timeseries',
'section.browser': 'Browser Distribution',
'section.os': 'OS Distribution',
// nav
'nav.back': 'Back to site',
'nav.logout': 'Log out',
'nav.default_label': 'Stream List',
// hero
'hero.title': 'Stream Admin',
// admin page
'admin.title_suffix': 'Admin',
'admin.hero_desc': 'Manage streams, sources and access for {site}.',
// menu
'menu.dashboard': 'Dashboard',
'menu.streams': 'Streams',
'menu.obs': 'Stream Setup',
'menu.telegram': 'Telegram',
'menu.site': 'Site Settings',
// loading / login
'loading.auth': 'Entering Admin',
'loading.auth_hint': 'Verifying session...',
'login.title': 'Admin Login',
'login.placeholder': 'Enter password',
'login.submit': 'Sign In',
'login.error': 'Incorrect password',
// dashboard cards
'dash.online': 'Online Now',
'dash.online_sub': 'Active in 45s',
'dash.today': "Today's Views",
'dash.today_sub': 'New sessions today',
'dash.total_hist': 'Total Views',
'dash.total_sub': 'All sessions',
'dash.unique': 'Unique Visitors',
'dash.unique_sub': 'Deduped visitor IDs',
'dash.export': '⬇ Export CSV',
'dash.exporting': 'Exporting...',
'dash.stream_detail': 'Stream Statistics',
// device labels
'device.desktop': 'Desktop',
'device.mobile': 'Mobile',
'device.tablet': 'Tablet',
// range buttons
'range.today': 'Today',
'range.7d': 'Last 7 days',
'range.30d': 'Last 30 days',
'range.all': 'All time',
'range.7d_short': '7d',
'range.30d_short': '30d',
// chart titles
'chart.today': 'Today (hourly)',
'chart.7d': 'Last 7 days',
'chart.30d': 'Last 30 days',
'chart.unit': 'views',
// table headers
'table.stream_name': 'Stream',
'table.online': 'Online',
'table.today': 'Today',
'table.total': 'Total',
'table.unique': 'Unique',
'table.avg_dur': 'Avg Dur',
'table.device': 'Device',
'table.last_active': 'Last Seen',
'table.no_data': 'No viewing data yet',
'table.empty': 'Click "Dashboard" to load',
'table.deleted': 'Deleted',
// geo
'geo.title': 'Geography',
'geo.no_data': 'No geo data',
// stat modal
'stat.connecting': 'Connecting...',
'stat.loading': 'Loading...',
'stat.no_records': 'No records',
'stat.online': 'Online Now',
'stat.today': "Today's Views",
'stat.total': 'Total Views',
'stat.unique': 'Unique Visitors',
'stat.device': 'Devices',
'stat.timeseries': 'Timeseries',
'stat.browser': 'Browsers',
'stat.os': 'Operating Systems',
'stat.geo': 'Geography',
'stat.sessions': 'Recent Sessions',
'stat.title': 'Stream Stats',
// pagination
'page.prev': 'Prev',
'page.next': 'Next',
'page.page_of': 'Page {cur} of {total}',
'page.total': '{n} total',
// session sort
'sort.last_seen': 'Last active',
'sort.started_at': 'Started',
'sort.duration': 'Duration',
'sort.device': 'Device',
'sort.browser': 'Browser',
'sort.ip': 'IP',
// stream list
'streams.per_page': 'Per page',
'streams.showing': '{shown} / {total} streams',
'streams.no_match': 'No matching streams',
// live indicator
'live.live': 'Live',
'live.disconnected':'Disconnected',
// hbar empty
'hbar.no_data': 'No data',
// session
'sess.watching': 'Watching',
'sess.dur': 'Duration',
'sess.online': 'Online',
// editor
'editor.add_title': 'Add Stream',
'editor.edit_title':'Edit Stream',
// lang
'lang.btn_label': '中',
// shared
'status.loading': 'Loading...',
// actions
'action.disable': 'Disable',
'action.enable': 'Enable',
// relative time
'rel.just_now': 'just now',
'rel.min_ago': '{n}min ago',
'rel.hour_ago': '{n}h ago',
'rel.day_ago': '{n}d ago',
// session detail
'sess.lt1min': '< 1min',
// stream list live stats
'ss.live_stat': 'Live {a} · Today {b} · Total {c}',
// stream action buttons
'action.stats': 'Stats',
'action.copy_link': 'Copy link',
'action.delete': 'Delete',
'action.push': 'Push',
'action.push_on': 'Disable push',
'action.push_off': 'Enable push',
'action.more': 'More',
// form labels
'form.site_title': 'Site Title',
'form.site_desc': 'Site Description',
'form.site_icon': 'Site Icon URL',
'form.site_url': 'Public Site URL',
'form.nav_links': 'Homepage Nav Links',
'form.footer': 'Footer Markdown',
'form.current_pw': 'Current Password',
'form.new_pw': 'New Password',
'form.confirm_pw': 'Confirm New Password',
'form.tg_chat_id': 'Group or User ID',
'form.obs_key': 'Stream Key',
'form.obs_host': 'RTMP Host/IP',
'form.obs_playback':'HLS/FLV Playback URL',
'form.obs_server': 'RTMP Server',
'form.obs_key_disp':'Stream Key',
'form.obs_hls': 'HLS Playback URL',
'form.obs_flv': 'FLV Playback URL',
'form.obs_route_key':'Add Stream Key',
'form.stream_name': 'Stream Name',
'form.stream_pw': 'Password (empty = public)',
'form.stream_type': 'Stream Type',
'form.hide_home': 'Hide from homepage',
// section headings
'h3.security': 'Security',
'h3.api_keys': 'API Keys',
'hint.api_keys': 'For programmatic access to admin endpoints. Send Authorization: Bearer <token> header or ?api_key=<token> query param. The token is only shown once on creation.',
'form.api_key_label': 'Key label',
'ph.api_key_label': 'Label (optional)',
'btn.create_api_key': 'Generate key',
'api.new_token_hint': 'Copy and keep safe — this token will not be shown again',
'api.copy': 'Copy',
'api.copied': 'Copied',
'api.revoke': 'Revoke',
'api.never_used': 'Never used',
'api.last_used': 'Last used: ',
'api.no_keys': 'No API keys',
'api.confirm_revoke': 'Revoke this key? This cannot be undone.',
'h3.obs_routes': 'Hidden Playback Routes',
'h3.links': 'Sources',
// buttons
'btn.add_nav_link': '+ Add Link',
'btn.save_site': 'Save Settings',
'btn.update_pw': 'Update Password',
'btn.save_tg': 'Save TG Settings',
'btn.tg_test': 'Send Test Message',
'btn.copy': 'Copy',
'btn.use_obs_hls': 'Use HLS in Stream Form',
'btn.use_obs_flv': 'Use FLV in Stream Form',
'btn.gen_route': 'Generate',
'btn.add_stream': '+ Add',
'btn.reset_filter': 'Clear',
'btn.add_link': '+ Add Source',
'btn.save': 'Save',
'btn.cancel': 'Cancel',
'btn.delete': 'Delete',
'btn.copy_hls': 'Copy HLS',
'btn.copy_flv': 'Copy FLV',
'btn.use_hls': 'Use HLS',
'btn.use_flv': 'Use FLV',
'btn.open': 'Open',
'btn.edit': 'Edit',
// placeholders
'ph.site_desc': 'English description (optional)',
'ph.site_icon': '/favicon.ico or https://example.com/favicon.png',
'ph.site_url': 'Defaults to current admin URL',
'ph.footer': 'Supports headings, paragraphs, lists and links. Leave empty to hide footer.',
'ph.obs_host': 'e.g. live-rtmp.stdm.moe or live-rtmp.stdm.moe:1935',
'ph.obs_playback': 'e.g. https://live.example.com',
'ph.obs_route_key': 'e.g. my-live-001',
'ph.search': 'Search by name / ID / URL',
'ph.nav_label': 'Label',
'ph.nav_url': '#stream-list or https://example.com',
'ph.link_name': 'Source name',
'ph.link_url': 'URL (m3u8/flv/mpd)',
'ph.key_aes': 'AES-128 Key Hex, multi-line: main-video=hex',
'ph.clearkey': 'ClearKey JSON, e.g. {"kid":"key"}',
// hints
'hint.nav_links': 'Leave empty to hide nav links. Supports anchors like #stream-list or full URLs.',
'hint.pw_change': 'Changing the admin password will log you out of the current session.',
'hint.tg_config': 'Configure Bot Token and recipient ID to send notifications on stream start/stop.',
'hint.tg_vars': 'Variables: {title}, {url}, {site_title}, {time}, {status}, {stream_id}, {public_id}, {link_name}, {source_url} &nbsp;·&nbsp; HTML supported',
'hint.obs_rtmp': 'The encoder pushes via RTMP to NAS; the player uses HLS or FLV from SRS.',
'hint.obs_routes': 'Adding a custom stream key generates a public HLS/FLV URL that cannot be reverse-engineered.',
'hint.links': 'Format: name | type | URL | Key Override | ClearKey',
'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)',
// TG
'tg.live_section': 'Live', 'tg.live_label': '直播',
'tg.archive_section':'Archive','tg.archive_label': '存档',
'tg.live_start': 'Notify on stream start',
'tg.live_stop': 'Notify on stream stop',
'tg.archive_start': 'Notify on archive publish',
'tg.archive_stop': 'Notify on archive unpublish',
'tg.template': 'Message template',
// filter/sort options
'filter.all': 'All',
'filter.enabled': 'Enabled',
'filter.disabled': 'Disabled',
'filter.public': 'Public',
'filter.password': 'Password',
'filter.hidden': 'Hidden',
'filter.telegram': 'TG Push',
'sort.default': 'Default',
'sort.newest': 'Newest',
'sort.updated': 'Recently updated',
'sort.name': 'Name AZ',
'sort.links': 'Most sources',
'pgsz.5': '5', 'pgsz.10': '10', 'pgsz.20': '20',
'pgsz.30': '30','pgsz.40': '40', 'pgsz.50': '50', 'pgsz.100': '100',
// stream badges
'badge.hidden': 'H',
'badge.closed': 'Off',
'badge.tg': 'TG',
// stream meta
'meta.links': 'sources',
'meta.id': 'ID',
'meta.pub_id': 'Public ID',
// probe status
'probe.no_info': 'No stream detected',
'probe.detecting': 'Detecting stream...',
'probe.detected': 'Stream detected',
'probe.waiting': 'Waiting for detection...',
'probe.closed': 'Stream disabled',
// status messages
'msg.site_saved': 'Settings saved',
'msg.site_err': 'Failed to load settings',
'msg.pw_min': 'Password must be at least 10 characters',
'msg.pw_mismatch': 'Passwords do not match',
'msg.pw_saved': 'Password updated, please log in again',
'msg.tg_saved': 'TG settings saved',
'msg.tg_err': 'Failed to load TG settings',
'msg.tg_test': 'Test message sent',
'msg.no_routes': 'No stream key mappings yet.',
'msg.obs_err': 'Failed to load stream key mappings',
'msg.route_added': 'Stream key mapping created',
'msg.route_deleted':'Stream key mapping deleted',
'msg.copied': 'Copied',
'msg.no_key': 'Please fill in stream key first',
'msg.sort_err': 'Failed to save order',
// confirm/alert
'confirm.delete': 'Confirm delete?',
'confirm.delete_route': 'Delete this stream key mapping?',
'alert.link_copied': 'Link copied!',
// theme
'theme.to_dark': 'Switch to dark mode',
'theme.to_light': 'Switch to light mode',
// type selector
'type.auto': 'Auto',
// OBS link names
'obs.local_hls': 'Local HLS',
'obs.local_flv': 'Local FLV',
'obs.hidden_hls': 'Hidden HLS',
'obs.hidden_flv': 'Hidden FLV',
'obs.default_name': 'Local Stream',
'obs.filled_in': '{name} added to stream form',
// API error codes from server
'err.stream_not_found': 'Stream not found',
'err.stream_disabled': 'Stream is disabled',
'err.stream_not_found_or_disabled':'Stream not found or disabled',
'err.stream_name_empty': 'Stream name cannot be empty',
'err.stream_key_empty': 'Stream key cannot be empty',
'err.stream_key_too_long': 'Stream key too long',
'err.missing_stream_id': 'Missing stream ID',
'err.auth_incorrect_password': 'Incorrect password',
'err.auth_current_pw_wrong': 'Current password is incorrect',
'err.auth_pw_too_short': 'New password must be at least 10 characters',
'err.auth_pw_mismatch': 'Passwords do not match',
'err.auth_required': 'Not authenticated or session expired',
'err.missing_session_id': 'Missing session ID',
'err.viewer_session_not_found': 'Viewer session not found',
'err.site_title_empty': 'Site title cannot be empty',
'err.site_icon_invalid': 'Invalid site icon URL',
'err.tg_config_missing': 'Telegram bot token and chat ID are required',
'err.tg_api_error': 'Telegram API error',
'err.tg_api_invalid': 'Telegram API returned an invalid response',
'err.obs_ids_required': 'Stream key IDs and label are required',
'err.missing_route_id': 'Missing route ID',
'err.route_not_found': 'Route not found',
'err.missing_id': 'Missing ID',
'err.invalid_action': 'Invalid action',
'err.server_error': 'Internal server error',
}
};
var LANG = localStorage.getItem('lang_pref') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
function t(key) {
return (TRANSLATIONS[LANG] && TRANSLATIONS[LANG][key] != null)
? TRANSLATIONS[LANG][key]
: (TRANSLATIONS.zh[key] != null ? TRANSLATIONS.zh[key] : key);
}
function applyI18n() {
document.querySelectorAll('[data-i18n]').forEach(function(el) {
var v = t(el.dataset.i18n);
if (el.tagName === 'INPUT' && el.type !== 'submit' && el.type !== 'button' && el.type !== 'checkbox' && el.type !== 'radio') {
el.placeholder = v;
} else if (el.tagName === 'TEXTAREA') {
el.placeholder = v;
} else {
el.textContent = v;
}
});
document.querySelectorAll('[data-i18n-title]').forEach(function(el) {
var v = t(el.dataset.i18nTitle);
el.title = v;
el.setAttribute('aria-label', v);
});
document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
document.documentElement.dataset.lang = LANG;
var btn = document.getElementById('lang-toggle');
if (btn) btn.textContent = t('lang.btn_label');
}
// Callbacks registered by different script blocks that need to re-render
// when the language changes (e.g. sort tab labels, per-language form fields).
// Each hook is wrapped in try/catch so a failure in one does not block others.
var _langChangeHooks = [];
function setLang(lang) {
LANG = lang;
localStorage.setItem('lang_pref', lang);
applyI18n();
_langChangeHooks.forEach(function(fn) { try { fn(); } catch(e) {} });
}
const escapeHtml = (text) => String(text ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[char]));
const escapeAttr = escapeHtml;
document.addEventListener('DOMContentLoaded', () => {
const themeButtons = Array.from(document.querySelectorAll('.theme-toggle:not(#lang-toggle)'));
const applyTheme = (theme) => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('site_theme', theme);
const label = theme === 'dark' ? t('theme.to_light') : t('theme.to_dark');
themeButtons.forEach(btn => {
btn.textContent = theme === 'dark' ? '☀️' : '🌙';
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
});
};
const switchThemeWithRipple = (button) => {
const nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
const rect = button.getBoundingClientRect();
document.documentElement.style.setProperty('--theme-ripple-x', `${rect.left + rect.width / 2}px`);
document.documentElement.style.setProperty('--theme-ripple-y', `${rect.top + rect.height / 2}px`);
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
applyTheme(nextTheme);
return;
}
document.startViewTransition(() => applyTheme(nextTheme));
};
const savedTheme = localStorage.getItem('site_theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(savedTheme || (prefersDark ? 'dark' : 'light'));
themeButtons.forEach(btn => {
btn.addEventListener('click', () => {
switchThemeWithRipple(btn);
});
});
// lang toggle (handler registered later after all deps are declared)
applyI18n();
_langChangeHooks.push(() => {
if (allStreams.length) renderList();
applyTheme(document.documentElement.dataset.theme || 'light');
const heroDesc = document.getElementById('admin-hero-desc');
if (heroDesc) {
const siteTitle = document.getElementById('admin-site-brand')?.textContent || 'StreamHall';
heroDesc.textContent = t('admin.hero_desc').replace('{site}', siteTitle);
}
});
const adminMenuButtons = Array.from(document.querySelectorAll('.admin-menu-btn'));
const adminViews = Array.from(document.querySelectorAll('[data-admin-view]'));
let activeRecordsLabel = localStorage.getItem('admin_records_label') || 'live';
const updateSubBtns = () => {
const recordsActive = adminViews.some(v => v.dataset.adminView === 'records' && v.classList.contains('active'));
document.querySelectorAll('.admin-sub-btn').forEach(b => {
b.classList.toggle('active', recordsActive && b.dataset.streamLabel === activeRecordsLabel);
});
document.getElementById('records-group')?.classList.toggle('in-view', recordsActive);
};
const switchAdminView = (target, pushState = true) => {
const next = adminViews.some(view => view.dataset.adminView === target) ? target : 'records';
adminViews.forEach(view => view.classList.toggle('active', view.dataset.adminView === next));
adminMenuButtons.forEach(btn => {
const active = btn.dataset.adminViewTarget === next;
btn.classList.toggle('active', active);
btn.setAttribute('aria-current', active ? 'page' : 'false');
});
document.getElementById('records-group')?.classList.toggle('in-view', next === 'records');
updateSubBtns();
localStorage.setItem('admin_active_view', next);
if (pushState) history.replaceState(null, '', `#${next}`);
};
adminMenuButtons.forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.adminViewTarget) switchAdminView(btn.dataset.adminViewTarget);
});
});
document.getElementById('records-group-toggle')?.addEventListener('click', () => {
document.getElementById('records-group')?.classList.toggle('open');
});
document.querySelectorAll('.admin-sub-btn').forEach(btn => {
btn.addEventListener('click', () => {
activeRecordsLabel = btn.dataset.streamLabel;
localStorage.setItem('admin_records_label', activeRecordsLabel);
streamLabelFilter = activeRecordsLabel;
currentPage = 1;
switchAdminView('records');
renderList();
});
});
const initialView = (location.hash || '').replace(/^#/, '') || localStorage.getItem('admin_active_view') || 'dashboard';
switchAdminView(initialView, false);
let _siteDescZh = '';
let _siteDescEn = '';
let _navLinksZh = [];
let _navLinksEn = [];
let _footerMarkdownZh = '';
let _footerMarkdownEn = '';
const els = {
authLoading: document.getElementById('auth-loading'),
pwdModal: document.getElementById('password-modal'),
panel: document.getElementById('admin-panel'),
pwdForm: document.getElementById('password-form'),
pwdInput: document.getElementById('password-input'),
siteSettingsForm: document.getElementById('site-settings-form'),
siteTitleInput: document.getElementById('site-title-input'),
siteDescriptionInput: document.getElementById('site-description-input'),
siteIconUrl: document.getElementById('site-icon-url'),
sitePublicBaseUrl: document.getElementById('site-public-base-url'),
siteNavLinksContainer: document.getElementById('site-nav-links-container'),
siteAddNavLink: document.getElementById('site-add-nav-link'),
siteFooterMarkdown: document.getElementById('site-footer-markdown'),
siteSettingsStatus: document.getElementById('site-settings-status'),
adminPasswordForm: document.getElementById('admin-password-form'),
adminCurrentPassword: document.getElementById('admin-current-password'),
adminNewPassword: document.getElementById('admin-new-password'),
adminConfirmPassword: document.getElementById('admin-confirm-password'),
adminPasswordStatus: document.getElementById('admin-password-status'),
apiKeyLabelInput: document.getElementById('api-key-label-input'),
apiKeyCreateBtn: document.getElementById('api-key-create-btn'),
apiKeyNewToken: document.getElementById('api-key-new-token'),
apiKeyNewTokenValue: document.getElementById('api-key-new-token-value'),
apiKeyCopyBtn: document.getElementById('api-key-copy-btn'),
apiKeysList: document.getElementById('api-keys-list'),
apiKeysStatus: document.getElementById('api-keys-status'),
telegramForm: document.getElementById('telegram-settings-form'),
telegramBotToken: document.getElementById('telegram-bot-token'),
telegramChatId: document.getElementById('telegram-chat-id'),
telegramLiveNotifyStart: document.getElementById('telegram-live-notify-start'),
telegramLiveNotifyStop: document.getElementById('telegram-live-notify-stop'),
telegramLiveStartTemplate: document.getElementById('telegram-live-start-template'),
telegramLiveStopTemplate: document.getElementById('telegram-live-stop-template'),
telegramArchiveNotifyStart: document.getElementById('telegram-archive-notify-start'),
telegramArchiveNotifyStop: document.getElementById('telegram-archive-notify-stop'),
telegramArchiveStartTemplate: document.getElementById('telegram-archive-start-template'),
telegramArchiveStopTemplate: document.getElementById('telegram-archive-stop-template'),
telegramStatus: document.getElementById('telegram-status'),
telegramTestBtn: document.getElementById('telegram-test-btn'),
obsStreamKey: document.getElementById('obs-stream-key'),
obsRtmpHost: document.getElementById('obs-rtmp-host'),
obsPlaybackOrigin: document.getElementById('obs-playback-origin'),
obsServerUrl: document.getElementById('obs-server-url'),
obsStreamKeyDisplay: document.getElementById('obs-stream-key-display'),
obsHlsUrl: document.getElementById('obs-hls-url'),
obsFlvUrl: document.getElementById('obs-flv-url'),
obsRouteKey: document.getElementById('obs-route-key'),
obsRouteAddBtn: document.getElementById('obs-route-add-btn'),
obsRouteList: document.getElementById('obs-route-list'),
obsStatus: document.getElementById('obs-status'),
streamCount: document.getElementById('admin-stream-count'),
streamSearch: document.getElementById('admin-stream-search'),
streamFilter: document.getElementById('admin-stream-filter'),
streamSort: document.getElementById('admin-stream-sort'),
streamReset: document.getElementById('admin-stream-reset'),
pageSize: document.getElementById('admin-page-size'),
pageInfo: document.getElementById('admin-page-info'),
prevPage: document.getElementById('admin-prev-page'),
nextPage: document.getElementById('admin-next-page'),
list: document.getElementById('admin-stream-list'),
form: document.getElementById('stream-form'),
title: document.getElementById('form-title'),
idInput: document.getElementById('stream-id'),
nameInput: document.getElementById('event-name'),
streamLabelInput: document.getElementById('stream-label'),
passInput: document.getElementById('stream-password'),
hiddenInput: document.getElementById('is-hidden'),
linksContainer: document.getElementById('links-container'),
cancelBtn: document.getElementById('cancel-btn')
};
let allStreams = [];
let streamStats = {};
let obsRoutes = [];
let isSavedProbeRunning = false;
const savedProbeCache = new Map();
const linkProbeTimers = new WeakMap();
const probeIntervalMs = 10000;
const pageSizeOptions = [5, 10, 20, 30, 40, 50];
let currentPage = 1;
let pageSize = Number(localStorage.getItem('admin_stream_page_size') || 5);
let streamSearchQuery = '';
let streamFilter = 'all';
let streamLabelFilter = activeRecordsLabel;
let streamSort = 'default';
if (!pageSizeOptions.includes(pageSize)) pageSize = 5;
els.pageSize.value = String(pageSize);
const apiCall = async (action, body = null) => {
const method = body ? 'POST' : 'GET';
const headers = body ? { 'Content-Type': 'application/json' } : {};
const options = { method, headers };
if (body) options.body = JSON.stringify(body);
const res = await fetch(`/api?action=${encodeURIComponent(action)}&_t=${Date.now()}`, options);
const data = await res.json();
if (!res.ok || data.status === 'error') {
const base = data.code ? (t('err.' + data.code) || data.code) : (data.message || 'API Error');
const detail = data.detail ? ` (${data.detail})` : '';
throw new Error(base + detail);
}
return data;
};
const showStatus = (el, msg, isErr = false) => {
el.innerText = msg;
el.className = `status ${isErr ? 'error' : ''}`;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 5000);
};
const copyText = async (text) => {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const temp = document.createElement('textarea');
temp.value = text;
temp.setAttribute('readonly', '');
temp.style.position = 'fixed';
temp.style.opacity = '0';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
temp.remove();
};
const inferLinkType = (url, explicitType = '') => {
if (explicitType) return explicitType;
const path = String(url || '').split('?')[0].toLowerCase();
if (path.endsWith('.m3u8')) return 'm3u8';
if (path.endsWith('.flv')) return 'flv';
if (path.endsWith('.mpd')) return 'dash';
return '';
};
const setProbeStatus = (el, state, message) => {
if (!el) return;
el.textContent = message || '';
el.className = 'stream-check-status';
if (state) el.classList.add(state);
};
const getSavedProbeEl = (streamId) => {
const safeId = String(streamId).replace(/["\\]/g, '\\$&');
return els.list.querySelector(`[data-stream-probe="${safeId}"]`);
};
const setSavedProbeStatus = (streamId, state, message) => {
const el = getSavedProbeEl(streamId);
const nextMessage = message || '';
savedProbeCache.set(String(streamId), {
state: state || '',
message: nextMessage,
complete: state !== 'is-checking'
});
if (!el) return;
if (el.dataset.probeState === state && el.dataset.probeMessage === nextMessage) return;
el.textContent = nextMessage;
el.className = 'stream-live-state';
if (state) el.classList.add(state);
el.dataset.probeState = state || '';
el.dataset.probeMessage = nextMessage;
if (state !== 'is-checking') el.dataset.probeComplete = '1';
};
const probeUrl = async (url, type = '') => {
const res = await apiCall('check_stream_url', {
url,
type: inferLinkType(url, type)
});
return res.data || { valid: false, message: t('probe.no_info') };
};
const checkLinkRow = async (row, silent = false) => {
if (!row || !row.isConnected || row.dataset.probeActive === '1') return;
const input = row.querySelector('.l-url');
const statusEl = row.querySelector('.stream-check-status');
const url = input?.value.trim() || '';
if (!url) {
setProbeStatus(statusEl, '', '');
return;
}
const type = row.querySelector('.l-type')?.value || '';
const token = `${Date.now()}-${Math.random()}`;
row.dataset.probeToken = token;
row.dataset.probeActive = '1';
if (!silent) setProbeStatus(statusEl, 'is-checking', t('probe.detecting'));
try {
const result = await probeUrl(url, type);
if (!row.isConnected || row.dataset.probeToken !== token) return;
setProbeStatus(
statusEl,
result.valid ? 'is-online' : 'is-offline',
result.valid ? t('probe.detected') : t('probe.no_info')
);
} catch (e) {
if (row.isConnected && row.dataset.probeToken === token) {
setProbeStatus(statusEl, 'is-offline', t('probe.no_info'));
}
} finally {
if (row.dataset.probeToken === token) delete row.dataset.probeActive;
}
};
const scheduleLinkRowCheck = (row, delay = 700) => {
window.clearTimeout(linkProbeTimers.get(row));
const input = row.querySelector('.l-url');
if (!input?.value.trim()) {
setProbeStatus(row.querySelector('.stream-check-status'), '', '');
return;
}
setProbeStatus(row.querySelector('.stream-check-status'), 'is-checking', t('probe.waiting'));
linkProbeTimers.set(row, window.setTimeout(() => checkLinkRow(row), delay));
};
const checkFormLinks = () => {
Array.from(els.linksContainer.children).forEach(row => {
if (row.querySelector('.l-url')?.value.trim()) checkLinkRow(row, true);
});
};
const applyProbeResult = (streamId, result) => {
setSavedProbeStatus(
streamId,
result.valid ? 'is-online' : 'is-offline',
result.valid ? t('probe.detected') : (t('probe.' + result.code) || t('probe.no_info'))
);
};
const checkSingleSavedStream = async (stream, showChecking = false) => {
if (!stream || Number(stream.is_enabled ?? 1) !== 1) {
if (stream) setSavedProbeStatus(stream.id, 'is-offline', t('probe.closed'));
return;
}
if (showChecking) setSavedProbeStatus(stream.id, 'is-checking', t('probe.detecting'));
try {
const res = await apiCall('check_stream', { id: stream.id });
applyProbeResult(stream.id, res.data || { valid: false });
} catch (e) {
setSavedProbeStatus(stream.id, 'is-offline', t('probe.no_info'));
}
};
const checkSavedStreams = async () => {
if (isSavedProbeRunning || allStreams.length === 0) return;
isSavedProbeRunning = true;
try {
for (const stream of allStreams) {
if (Number(stream.is_enabled ?? 1) !== 1) {
setSavedProbeStatus(stream.id, 'is-offline', t('probe.closed'));
continue;
}
const probeEl = getSavedProbeEl(stream.id);
if (!probeEl?.dataset.probeComplete) {
setSavedProbeStatus(stream.id, 'is-checking', t('probe.detecting'));
}
try {
const res = await apiCall('check_stream', { id: stream.id });
const result = res.data || { valid: false };
applyProbeResult(stream.id, result);
} catch (e) {
setSavedProbeStatus(stream.id, 'is-offline', t('probe.no_info'));
}
}
} finally {
isSavedProbeRunning = false;
}
};
const showLogin = () => {
els.authLoading.classList.add('hidden');
els.panel.classList.add('hidden');
els.pwdModal.classList.remove('hidden');
els.pwdInput.focus();
};
const enterPanel = () => {
els.authLoading.classList.add('hidden');
els.pwdModal.classList.add('hidden');
els.panel.classList.remove('hidden');
loadSiteSettings();
loadApiKeys();
loadTelegramSettings();
loadObsRoutes();
loadStreams();
const view = (location.hash || '').replace(/^#/, '') || localStorage.getItem('admin_active_view') || 'dashboard';
if (view === 'dashboard') document.dispatchEvent(new CustomEvent('admin:enter-dashboard'));
};
apiCall('session').then(res => {
if (res.logged_in) {
enterPanel();
return;
}
showLogin();
}).catch(showLogin);
els.pwdForm.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await apiCall('login', { password: els.pwdInput.value });
enterPanel();
} catch (e) {
const err = document.getElementById('password-error');
err.textContent = e.message || t('login.error');
err.classList.remove('hidden');
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await apiCall('logout');
window.location.reload();
});
const parseNavLinks = (value) => {
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
};
const addNavLinkUI = (label = '', url = '') => {
const row = document.createElement('div');
row.className = 'nav-link-row';
row.innerHTML = `
<input class="nav-label" maxlength="24" placeholder="${t('ph.nav_label')}" value="${escapeAttr(label)}">
<input class="nav-url" maxlength="300" placeholder="${t('ph.nav_url')}" value="${escapeAttr(url)}">
<button type="button" class="btn-danger nav-remove-btn">${t('btn.delete')}</button>
`;
row.querySelector('.nav-remove-btn').addEventListener('click', () => row.remove());
els.siteNavLinksContainer.appendChild(row);
};
const collectNavLinks = () => Array.from(els.siteNavLinksContainer.children).map(row => ({
label: row.querySelector('.nav-label')?.value.trim() || '',
url: row.querySelector('.nav-url')?.value.trim() || ''
})).filter(link => link.label && link.url);
// Register lang toggle handler here — all deps (els, collectNavLinks, addNavLinkUI, _siteDesc*) are now declared
document.getElementById('lang-toggle')?.addEventListener('click', () => {
if (LANG === 'zh') {
_siteDescZh = els.siteDescriptionInput.value;
_navLinksZh = collectNavLinks();
_footerMarkdownZh = els.siteFooterMarkdown.value;
} else {
_siteDescEn = els.siteDescriptionInput.value;
_navLinksEn = collectNavLinks();
_footerMarkdownEn = els.siteFooterMarkdown.value;
}
setLang(LANG === 'zh' ? 'en' : 'zh');
els.siteDescriptionInput.value = LANG === 'zh' ? _siteDescZh : (_siteDescEn || _siteDescZh);
els.siteNavLinksContainer.innerHTML = '';
(LANG === 'zh' ? _navLinksZh : (_navLinksEn.length ? _navLinksEn : _navLinksZh)).forEach(link => addNavLinkUI(link.label, link.url));
els.siteFooterMarkdown.value = LANG === 'zh' ? _footerMarkdownZh : (_footerMarkdownEn || _footerMarkdownZh);
});
const applySiteIcon = (url = '') => {
const iconUrl = String(url || '').trim();
let link = document.querySelector('link[rel="icon"]');
if (!iconUrl) {
if (link) link.remove();
return;
}
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = iconUrl;
};
const applySiteSettings = (settings = {}) => {
const siteTitle = settings.site_title || 'StreamHall';
_siteDescZh = settings.site_description || '';
_siteDescEn = settings.site_description_en || '';
_navLinksZh = settings.site_nav_links == null
? [{ label: t('nav.default_label'), url: '#stream-list' }]
: parseNavLinks(settings.site_nav_links);
_navLinksEn = settings.site_nav_links_en == null
? [{ label: 'Streams', url: '#stream-list' }]
: parseNavLinks(settings.site_nav_links_en);
_footerMarkdownZh = settings.footer_markdown || '';
_footerMarkdownEn = settings.footer_markdown_en || '';
document.title = `${siteTitle} - ${t('admin.title_suffix')}`;
document.getElementById('admin-site-brand').textContent = siteTitle;
const heroDesc = document.getElementById('admin-hero-desc');
if (heroDesc) heroDesc.textContent = t('admin.hero_desc').replace('{site}', siteTitle);
els.siteTitleInput.value = siteTitle;
els.siteDescriptionInput.value = LANG === 'zh' ? _siteDescZh : (_siteDescEn || _siteDescZh);
els.siteIconUrl.value = settings.site_icon_url || '';
applySiteIcon(settings.site_icon_url || '');
els.siteNavLinksContainer.innerHTML = '';
(LANG === 'zh' ? _navLinksZh : (_navLinksEn.length ? _navLinksEn : _navLinksZh)).forEach(link => addNavLinkUI(link.label, link.url));
els.siteFooterMarkdown.value = LANG === 'zh' ? _footerMarkdownZh : (_footerMarkdownEn || _footerMarkdownZh);
els.sitePublicBaseUrl.placeholder = window.location.origin;
els.sitePublicBaseUrl.value = settings.telegram_public_base_url || window.location.origin;
localStorage.setItem('site_settings_cache', JSON.stringify({
site_title: siteTitle,
site_description: _siteDescZh,
site_description_en: _siteDescEn,
site_icon_url: els.siteIconUrl.value,
site_nav_links: JSON.stringify(_navLinksZh),
site_nav_links_en: JSON.stringify(_navLinksEn),
footer_markdown: _footerMarkdownZh,
footer_markdown_en: _footerMarkdownEn,
telegram_public_base_url: els.sitePublicBaseUrl.value
}));
};
const loadSiteSettings = async () => {
try {
const res = await apiCall('site_settings');
applySiteSettings(res.data);
} catch (e) {
showStatus(els.siteSettingsStatus, `${t('msg.site_err')}: ${e.message}`, true);
}
};
els.siteSettingsForm.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const curDescZh = LANG === 'zh' ? els.siteDescriptionInput.value : _siteDescZh;
const curDescEn = LANG === 'en' ? els.siteDescriptionInput.value : _siteDescEn;
const curNavLinks = LANG === 'zh' ? collectNavLinks() : _navLinksZh;
const curNavLinksEn = LANG === 'en' ? collectNavLinks() : _navLinksEn;
const curFooterZh = LANG === 'zh' ? els.siteFooterMarkdown.value : _footerMarkdownZh;
const curFooterEn = LANG === 'en' ? els.siteFooterMarkdown.value : _footerMarkdownEn;
const res = await apiCall('update_site_settings', {
siteTitle: els.siteTitleInput.value,
siteDescription: curDescZh,
siteDescriptionEn: curDescEn,
siteIconUrl: els.siteIconUrl.value.trim(),
navLinks: curNavLinks,
navLinksEn: curNavLinksEn,
footerMarkdown: curFooterZh,
footerMarkdownEn: curFooterEn,
publicBaseUrl: els.sitePublicBaseUrl.value.trim() || window.location.origin
});
applySiteSettings(res.data);
showStatus(els.siteSettingsStatus, t('msg.site_saved'));
} catch (e) {
showStatus(els.siteSettingsStatus, e.message, true);
}
});
els.siteAddNavLink.addEventListener('click', () => addNavLinkUI());
const renderApiKeys = (keys) => {
if (!els.apiKeysList) return;
if (!keys.length) {
els.apiKeysList.innerHTML = `<p style="color:var(--muted);font-size:.86rem;padding:8px 0;">${t('api.no_keys')}</p>`;
return;
}
els.apiKeysList.innerHTML = keys.map(k => {
const lastUsed = k.last_used_at
? `${t('api.last_used')}${fmtRel(k.last_used_at)}`
: t('api.never_used');
const label = k.label || `#${k.id}`;
return `<div style="display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--line);">
<div style="flex:1;min-width:0;">
<span style="font-weight:700;font-size:.88rem;">${escapeHtml(label)}</span>
<span style="color:var(--muted);font-size:.76rem;margin-left:8px;">${lastUsed}</span>
</div>
<button type="button" class="btn-warning" style="flex-shrink:0;padding:3px 10px;font-size:.78rem;" data-key-id="${k.id}" data-i18n="api.revoke">${t('api.revoke')}</button>
</div>`;
}).join('');
els.apiKeysList.querySelectorAll('[data-key-id]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(t('api.confirm_revoke'))) return;
try {
await apiCall('delete_api_key', { id: Number(btn.dataset.keyId) });
loadApiKeys();
} catch (err) {
showStatus(els.apiKeysStatus, err.message, true);
}
});
});
};
const loadApiKeys = async () => {
try {
const res = await apiCall('list_api_keys');
renderApiKeys(res.data || []);
} catch (err) {
showStatus(els.apiKeysStatus, err.message, true);
}
};
els.apiKeyCreateBtn?.addEventListener('click', async () => {
const label = els.apiKeyLabelInput?.value.trim() || '';
try {
const res = await apiCall('create_api_key', { label });
if (els.apiKeyNewTokenValue) els.apiKeyNewTokenValue.textContent = res.data.token;
if (els.apiKeyNewToken) els.apiKeyNewToken.classList.remove('hidden');
if (els.apiKeyLabelInput) els.apiKeyLabelInput.value = '';
loadApiKeys();
} catch (err) {
showStatus(els.apiKeysStatus, err.message, true);
}
});
els.apiKeyCopyBtn?.addEventListener('click', () => {
const val = els.apiKeyNewTokenValue?.textContent || '';
navigator.clipboard.writeText(val).then(() => {
const orig = els.apiKeyCopyBtn.textContent;
els.apiKeyCopyBtn.textContent = t('api.copied');
setTimeout(() => { els.apiKeyCopyBtn.textContent = t('api.copy'); }, 1800);
});
});
els.adminPasswordForm.addEventListener('submit', async (e) => {
e.preventDefault();
const newPassword = els.adminNewPassword.value.trim();
const confirmPassword = els.adminConfirmPassword.value.trim();
if (newPassword.length < 10) {
showStatus(els.adminPasswordStatus, t('msg.pw_min'), true);
return;
}
if (newPassword !== confirmPassword) {
showStatus(els.adminPasswordStatus, t('msg.pw_mismatch'), true);
return;
}
try {
await apiCall('update_admin_password', {
currentPassword: els.adminCurrentPassword.value,
newPassword,
confirmPassword
});
els.adminPasswordForm.reset();
showStatus(els.adminPasswordStatus, t('msg.pw_saved'));
window.setTimeout(() => window.location.reload(), 900);
} catch (e) {
showStatus(els.adminPasswordStatus, e.message, true);
}
});
const applyTelegramSettings = (settings = {}) => {
els.telegramBotToken.value = settings.telegram_bot_token || '';
els.telegramChatId.value = settings.telegram_chat_id || '';
els.telegramLiveNotifyStart.checked = settings.telegram_live_notify_start === '1';
els.telegramLiveNotifyStop.checked = settings.telegram_live_notify_stop === '1';
els.telegramLiveStartTemplate.value = settings.telegram_live_start_template || '【开播提醒】{title}\n观看地址:{url}\n时间:{time}';
els.telegramLiveStopTemplate.value = settings.telegram_live_stop_template || '【关播提醒】{title}\n时间:{time}';
els.telegramArchiveNotifyStart.checked = settings.telegram_archive_notify_start === '1';
els.telegramArchiveNotifyStop.checked = settings.telegram_archive_notify_stop === '1';
els.telegramArchiveStartTemplate.value = settings.telegram_archive_start_template || '【上架提醒】{title}\n观看地址:{url}\n时间:{time}';
els.telegramArchiveStopTemplate.value = settings.telegram_archive_stop_template || '【下架提醒】{title}\n时间:{time}';
};
const collectTelegramSettings = () => ({
botToken: els.telegramBotToken.value,
chatId: els.telegramChatId.value,
publicBaseUrl: els.sitePublicBaseUrl.value.trim() || window.location.origin,
liveNotifyStart: els.telegramLiveNotifyStart.checked,
liveNotifyStop: els.telegramLiveNotifyStop.checked,
liveStartTemplate: els.telegramLiveStartTemplate.value,
liveStopTemplate: els.telegramLiveStopTemplate.value,
archiveNotifyStart: els.telegramArchiveNotifyStart.checked,
archiveNotifyStop: els.telegramArchiveNotifyStop.checked,
archiveStartTemplate: els.telegramArchiveStartTemplate.value,
archiveStopTemplate: els.telegramArchiveStopTemplate.value,
});
const loadTelegramSettings = async () => {
try {
const res = await apiCall('telegram_settings');
applyTelegramSettings(res.data);
} catch (e) {
showStatus(els.telegramStatus, `${t('msg.tg_err')}: ${e.message}`, true);
}
};
els.telegramForm.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const res = await apiCall('update_telegram_settings', collectTelegramSettings());
applyTelegramSettings(res.data);
showStatus(els.telegramStatus, t('msg.tg_saved'));
} catch (e) {
showStatus(els.telegramStatus, e.message, true);
}
});
els.telegramTestBtn.addEventListener('click', async () => {
try {
await apiCall('test_telegram', collectTelegramSettings());
showStatus(els.telegramStatus, t('msg.tg_test'));
} catch (e) {
showStatus(els.telegramStatus, e.message, true);
}
});
const obsStreamKey = () => els.obsStreamKey.value.trim() || 'livestream';
const normalizeHost = (value = '') => {
const raw = String(value || '').trim();
if (!raw) return (window.location.hostname || 'NAS-IP') + ':1935';
try {
return new URL(raw.includes('://') ? raw : `http://${raw}`).host || raw;
} catch (e) {
return raw.replace(/^https?:\/\//i, '').split('/')[0] || window.location.hostname || 'NAS-IP';
}
};
const normalizeOrigin = (value = '') => {
const raw = String(value || '').trim().replace(/\/+$/, '');
if (!raw) {
const host = window.location.hostname || 'NAS-IP';
return `${window.location.protocol || 'http:'}//${host}:18088`;
}
try {
const url = new URL(raw.includes('://') ? raw : `${window.location.protocol || 'http:'}//${raw}`);
return url.origin;
} catch (e) {
return raw.includes('://') ? raw : `${window.location.protocol || 'http:'}//${raw}`;
}
};
const refreshObsUrls = () => {
const rtmpHost = normalizeHost(els.obsRtmpHost.value);
const playbackOrigin = normalizeOrigin(els.obsPlaybackOrigin.value);
const key = obsStreamKey();
const encodedKey = encodeURIComponent(key);
localStorage.setItem('obs_rtmp_host', els.obsRtmpHost.value.trim());
localStorage.setItem('obs_playback_origin', els.obsPlaybackOrigin.value.trim());
els.obsServerUrl.value = `rtmp://${rtmpHost}/live`;
els.obsStreamKeyDisplay.value = key;
els.obsHlsUrl.value = `${playbackOrigin}/live/${encodedKey}.m3u8`;
els.obsFlvUrl.value = `${playbackOrigin}/live/${encodedKey}.flv`;
renderObsRoutes();
};
const obsRouteUrls = (route) => {
const origin = normalizeOrigin(els.obsPlaybackOrigin.value);
const slug = encodeURIComponent(route.public_slug);
return {
hls: `${origin}/h/${slug}/index.m3u8`,
flv: `${origin}/h/${slug}.flv`
};
};
const renderObsRoutes = () => {
if (!els.obsRouteList) return;
if (!obsRoutes.length) {
els.obsRouteList.innerHTML = `<p class="hint">${t('msg.no_routes')}</p>`;
return;
}
els.obsRouteList.innerHTML = '';
obsRoutes.forEach(route => {
const urls = obsRouteUrls(route);
const card = document.createElement('div');
card.className = 'obs-route-card';
card.innerHTML = `
<div class="obs-route-title">
<span>${escapeHtml(route.stream_key)}</span>
<span class="stream-meta">/${escapeHtml(route.public_slug)}</span>
</div>
<div class="obs-route-links">
<div class="obs-copy-row">
<input type="text" value="${escapeAttr(urls.hls)}" readonly onclick="this.select()">
<button type="button" class="btn-secondary obs-route-copy-btn" data-url="${escapeAttr(urls.hls)}">${t('btn.copy_hls')}</button>
</div>
<div class="obs-copy-row">
<input type="text" value="${escapeAttr(urls.flv)}" readonly onclick="this.select()">
<button type="button" class="btn-secondary obs-route-copy-btn" data-url="${escapeAttr(urls.flv)}">${t('btn.copy_flv')}</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn-success obs-route-use-btn" data-kind="hls" data-key="${escapeAttr(route.stream_key)}" data-url="${escapeAttr(urls.hls)}">${t('btn.use_hls')}</button>
<button type="button" class="btn-secondary obs-route-use-btn" data-kind="flv" data-key="${escapeAttr(route.stream_key)}" data-url="${escapeAttr(urls.flv)}">${t('btn.use_flv')}</button>
<button type="button" class="btn-danger obs-route-delete-btn" data-id="${escapeAttr(route.id)}">${t('btn.delete')}</button>
</div>
`;
els.obsRouteList.appendChild(card);
});
};
const loadObsRoutes = async () => {
try {
const res = await apiCall('list_obs_routes');
obsRoutes = res.data || [];
renderObsRoutes();
} catch (e) {
showStatus(els.obsStatus, `${t('msg.obs_err')}: ${e.message}`, true);
}
};
const appendObsLink = (kind) => {
const url = kind === 'flv' ? els.obsFlvUrl.value : els.obsHlsUrl.value;
const type = kind === 'flv' ? 'flv' : 'm3u8';
const name = kind === 'flv' ? t('obs.local_flv') : t('obs.local_hls');
const rows = Array.from(els.linksContainer.children);
if (rows.length === 1) {
const firstName = rows[0].querySelector('.l-name')?.value.trim();
const firstUrl = rows[0].querySelector('.l-url')?.value.trim();
if (!firstName && !firstUrl) rows[0].remove();
}
if (!els.nameInput.value.trim()) els.nameInput.value = `${t('obs.default_name')} ${obsStreamKey()}`;
addLinkUI(name, url, '', '', type);
document.getElementById('editor-modal').classList.remove('hidden');
showStatus(els.obsStatus, t('obs.filled_in').replace('{name}', name));
};
let statsLiveTimer = null;
const refreshStreamStats = async () => {
try {
const res = await apiCall('stream_stats_summary');
streamStats = res.data || {};
document.querySelectorAll('.stream-stats-live').forEach(span => {
const s = streamStats[String(span.dataset.sid)] || {};
span.textContent = t('ss.live_stat').replace('{a}', Number(s.online || 0)).replace('{b}', Number(s.today_views || 0)).replace('{c}', Number(s.total_views || 0));
});
} catch (e) {}
};
const startStatsLive = () => {
clearInterval(statsLiveTimer);
statsLiveTimer = setInterval(refreshStreamStats, 5000);
};
const loadStreams = async () => {
try {
const res = await apiCall('list_admin');
allStreams = res.data;
try {
const statsRes = await apiCall('stream_stats_summary');
streamStats = statsRes.data || {};
} catch (statsError) {
streamStats = {};
console.warn('Load stream stats failed:', statsError);
}
renderList();
startStatsLive();
window.setTimeout(checkSavedStreams, 100);
} catch (e) {
alert(e.message);
}
};
const streamLinks = (stream) => {
try {
return JSON.parse(stream.links_json || '[]');
} catch (e) {
return [];
}
};
const streamMatchesFilter = (stream) => {
const links = streamLinks(stream);
const label = String(stream.stream_label || 'LIVE').toUpperCase();
// always apply sub-menu label filter
if (streamLabelFilter === 'live' && label === 'ARCHIVE') return false;
if (streamLabelFilter === 'archive' && label !== 'ARCHIVE') return false;
const enabled = Number(stream.is_enabled ?? 1) === 1;
const hasPassword = !!String(stream.stream_password || '');
switch (streamFilter) {
case 'enabled': return enabled;
case 'disabled': return !enabled;
case 'public': return !hasPassword;
case 'password': return hasPassword;
case 'hidden': return Number(stream.is_hidden || 0) === 1;
case 'telegram': return Number(stream.tg_notify_enabled || 0) === 1;
case 'key': return links.some(link => link.key && link.key.length > 0);
default: return true;
}
};
const streamMatchesSearch = (stream) => {
const query = streamSearchQuery.trim().toLowerCase();
if (!query) return true;
const links = streamLinks(stream);
const haystack = [
stream.event_name,
stream.id,
stream.public_id,
...links.flatMap(link => [link.name, link.url])
].join(' ').toLowerCase();
return haystack.includes(query);
};
const compareStreams = (a, b) => {
const linksA = streamLinks(a).length;
const linksB = streamLinks(b).length;
const labelOrder = lbl => lbl === 'LIVE' ? 0 : lbl === 'ARCHIVE' ? 1 : 2;
const labelA = String(a.stream_label || 'LIVE').toUpperCase();
const labelB = String(b.stream_label || 'LIVE').toUpperCase();
const fallback = (labelOrder(labelA) - labelOrder(labelB))
|| (Number(a.sort_order ?? 99999) - Number(b.sort_order ?? 99999))
|| (Number(b.id) - Number(a.id));
switch (streamSort) {
case 'created_desc': return Number(b.created_at || 0) - Number(a.created_at || 0);
case 'updated_desc': return Number(b.updated_at || 0) - Number(a.updated_at || 0);
case 'name_asc': return String(a.event_name || '').localeCompare(String(b.event_name || ''), 'zh-CN');
case 'links_desc': return (linksB - linksA) || fallback;
default: return fallback;
}
};
const currentStreamList = () => allStreams
.filter(stream => streamMatchesFilter(stream) && streamMatchesSearch(stream))
.sort(compareStreams);
const renderList = () => {
els.list.innerHTML = '';
const displayStreams = currentStreamList();
const total = displayStreams.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
currentPage = Math.min(Math.max(1, currentPage), totalPages);
const start = (currentPage - 1) * pageSize;
const pageStreams = displayStreams.slice(start, start + pageSize);
els.streamCount.textContent = t('streams.showing').replace('{shown}', total).replace('{total}', allStreams.length);
els.pageInfo.textContent = t('page.page_of').replace('{cur}', currentPage).replace('{total}', totalPages);
els.prevPage.disabled = currentPage <= 1;
els.nextPage.disabled = currentPage >= totalPages;
if (!pageStreams.length) {
els.streamCount.textContent = t('streams.showing').replace('{shown}', 0).replace('{total}', allStreams.length);
els.list.innerHTML = `<p class="hint">${t('streams.no_match')}</p>`;
return;
}
pageStreams.forEach(s => {
const links = streamLinks(s);
const hasKey = links.some(l => l.key && l.key.length > 0);
const tgEnabled = Number(s.tg_notify_enabled || 0) === 1;
const liveEnabled = Number(s.is_enabled ?? 1) === 1;
const streamLabel = String(s.stream_label || 'LIVE').toUpperCase() === 'ARCHIVE' ? 'ARCHIVE' : 'LIVE';
const publicId = s.public_id || s.id;
const stats = streamStats[String(s.id)] || {};
const statsSpan = `<span class="stream-stats-live" data-sid="${s.id}">${t('ss.live_stat').replace('{a}', Number(stats.online || 0)).replace('{b}', Number(stats.today_views || 0)).replace('{c}', Number(stats.total_views || 0))}</span>`;
const playerUrl = `${window.location.origin}/player.html?id=${encodeURIComponent(publicId)}`;
const cachedProbe = savedProbeCache.get(String(s.id));
const probeState = liveEnabled ? (cachedProbe?.state || 'is-checking') : 'is-offline';
const probeMessage = liveEnabled ? (cachedProbe?.message || t('probe.waiting')) : t('probe.closed');
const probeComplete = liveEnabled ? (cachedProbe?.complete ? '1' : '') : '1';
const div = document.createElement('div');
div.className = 'stream-row';
div.dataset.id = String(s.id);
div.dataset.label = streamLabel;
div.setAttribute('draggable', 'true');
div.innerHTML = `
<span class="drag-handle" draggable="false">⠿</span>
<div class="stream-main">
<div class="stream-name">
${escapeHtml(s.event_name)}
${s.is_hidden ? `<span class="badge">${t('badge.hidden')}</span>` : ''}
${liveEnabled ? '' : `<span class="badge badge-closed">${t('badge.closed')}</span>`}
${hasKey ? '<span class="badge badge-key">KeyOverride</span>' : ''}
${tgEnabled ? `<span class="badge badge-tg">${t('badge.tg')}</span>` : ''}
</div>
<div class="stream-meta">${links.length} ${t('meta.links')} | ${t('meta.id')} ${s.id} | ${t('meta.pub_id')} ${escapeHtml(publicId)} | ${statsSpan}</div>
<div class="stream-live-state ${escapeAttr(probeState)}" data-stream-probe="${escapeAttr(s.id)}" data-probe-state="${escapeAttr(probeState)}" data-probe-message="${escapeAttr(probeMessage)}" data-probe-complete="${escapeAttr(probeComplete)}">${escapeHtml(probeMessage)}</div>
</div>
<div class="actions stream-actions">
<button data-id="${s.id}" data-url="${escapeAttr(playerUrl)}" class="open-btn btn-secondary" type="button">${t('btn.open')}</button>
<button data-id="${s.id}" class="edit-btn btn-secondary" type="button">${t('btn.edit')}</button>
<details class="action-menu">
<summary class="more-btn">${t('action.more')}</summary>
<div class="action-menu-panel">
<button data-id="${s.id}" data-name="${escapeAttr(s.event_name)}" class="stream-stat-btn btn-secondary" type="button">${t('action.stats')}</button>
<button data-id="${s.id}" data-public-id="${escapeAttr(publicId)}" class="copy-btn btn-success" type="button">${t('action.copy_link')}</button>
<button data-id="${s.id}" data-enabled="${liveEnabled ? '1' : '0'}" class="live-toggle-btn ${liveEnabled ? 'btn-warning' : 'btn-success'}" type="button">${liveEnabled ? t('action.disable') : t('action.enable')}</button>
<button data-id="${s.id}" data-enabled="${tgEnabled ? '1' : '0'}" class="tg-toggle-btn ${tgEnabled ? 'btn-success' : 'btn-secondary'}" type="button" title="${tgEnabled ? t('action.push_on') : t('action.push_off')}" aria-label="${tgEnabled ? t('action.push_on') : t('action.push_off')}">${t('action.push')}</button>
<button data-id="${s.id}" class="del-btn btn-danger" type="button">${t('action.delete')}</button>
</div>
</details>
</div>
`;
els.list.appendChild(div);
});
setupDragHandles();
};
els.pageSize.addEventListener('change', () => {
pageSize = Number(els.pageSize.value) || 5;
localStorage.setItem('admin_stream_page_size', String(pageSize));
currentPage = 1;
renderList();
});
els.streamSearch.addEventListener('input', () => {
streamSearchQuery = els.streamSearch.value;
currentPage = 1;
renderList();
});
els.streamFilter.addEventListener('change', () => {
streamFilter = els.streamFilter.value;
currentPage = 1;
renderList();
});
els.streamSort.addEventListener('change', () => {
streamSort = els.streamSort.value;
currentPage = 1;
renderList();
});
els.streamReset.addEventListener('click', () => {
streamSearchQuery = '';
streamFilter = 'all';
streamSort = 'default';
els.streamSearch.value = '';
els.streamFilter.value = streamFilter;
els.streamSort.value = streamSort;
currentPage = 1;
renderList();
});
els.prevPage.addEventListener('click', () => {
currentPage = Math.max(1, currentPage - 1);
renderList();
});
els.nextPage.addEventListener('click', () => {
const totalPages = Math.max(1, Math.ceil(currentStreamList().length / pageSize));
currentPage = Math.min(totalPages, currentPage + 1);
renderList();
});
els.list.addEventListener('click', async (e) => {
const id = e.target.dataset.id;
if (e.target.classList.contains('more-btn')) {
const currentMenu = e.target.closest('.action-menu');
els.list.querySelectorAll('.action-menu[open]').forEach(menu => {
if (menu !== currentMenu) menu.removeAttribute('open');
});
}
if (!id) return;
if (e.target.classList.contains('del-btn')) {
if (confirm(t('confirm.delete'))) {
await apiCall('delete', { id });
loadStreams();
}
}
if (e.target.classList.contains('copy-btn')) {
const publicId = e.target.dataset.publicId || id;
const url = `${window.location.origin}/player.html?id=${encodeURIComponent(publicId)}`;
await copyText(url);
e.target.closest('.action-menu')?.removeAttribute('open');
alert(t('alert.link_copied'));
}
if (e.target.classList.contains('open-btn')) {
window.open(e.target.dataset.url, '_blank', 'noopener');
}
if (e.target.classList.contains('live-toggle-btn')) {
const enabled = e.target.dataset.enabled !== '1';
try {
const res = await apiCall('set_stream_enabled', { id, enabled });
const stream = allStreams.find(i => String(i.id) === String(id));
if (stream) stream.is_enabled = enabled ? 1 : 0;
if (!enabled) {
setSavedProbeStatus(id, 'is-offline', t('probe.closed'));
} else if (res.data) {
applyProbeResult(id, res.data);
}
renderList();
if (!enabled) {
setSavedProbeStatus(id, 'is-offline', t('probe.closed'));
} else if (res.data) {
applyProbeResult(id, res.data);
} else if (stream) {
checkSingleSavedStream(stream, true);
}
} catch (err) {
alert(err.message);
}
}
if (e.target.classList.contains('tg-toggle-btn')) {
const enabled = e.target.dataset.enabled !== '1';
try {
await apiCall('set_stream_tg_notify', { id, enabled });
const stream = allStreams.find(i => String(i.id) === String(id));
if (stream) stream.tg_notify_enabled = enabled ? 1 : 0;
renderList();
window.setTimeout(checkSavedStreams, 100);
} catch (err) {
alert(err.message);
}
}
if (e.target.classList.contains('edit-btn')) {
const s = allStreams.find(i => String(i.id) === String(id));
if (!s) return;
openEditorModal(s);
}
});
document.addEventListener('click', (e) => {
if (e.target.closest('.action-menu')) return;
els.list.querySelectorAll('.action-menu[open]').forEach(menu => {
menu.removeAttribute('open');
menu.closest('.stream-row')?.classList.remove('menu-open');
});
});
function setupDragHandles() {
let dragSrc = null;
let _dragFromHandle = false;
els.list.querySelectorAll('.stream-row').forEach(row => {
row.querySelector('.drag-handle')?.addEventListener('mousedown', () => { _dragFromHandle = true; });
row.addEventListener('dragstart', e => {
if (!_dragFromHandle) { e.preventDefault(); return; }
dragSrc = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => {
_dragFromHandle = false;
row.classList.remove('dragging');
els.list.querySelectorAll('.stream-row').forEach(r => r.classList.remove('drag-over'));
dragSrc = null;
});
row.addEventListener('dragover', e => {
e.preventDefault();
if (row !== dragSrc && row.dataset.label === dragSrc?.dataset.label) {
els.list.querySelectorAll('.stream-row').forEach(r => r.classList.remove('drag-over'));
row.classList.add('drag-over');
}
});
row.addEventListener('drop', async e => {
e.preventDefault();
if (!dragSrc || dragSrc === row) return;
if (row.dataset.label !== dragSrc.dataset.label) return;
const srcIdx = [...els.list.children].indexOf(dragSrc);
const dstIdx = [...els.list.children].indexOf(row);
if (srcIdx < dstIdx) els.list.insertBefore(dragSrc, row.nextSibling);
else els.list.insertBefore(dragSrc, row);
const label = row.dataset.label;
const newIds = [...els.list.querySelectorAll(`.stream-row[data-label="${label}"]`)]
.map(r => Number(r.dataset.id));
try {
await apiCall('reorder_streams', { label, ids: newIds });
newIds.forEach((id, idx) => {
const s = allStreams.find(x => x.id === id);
if (s) s.sort_order = idx;
});
} catch (err) {
alert(t('msg.sort_err') + '' + err.message);
renderList();
}
});
});
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!document.getElementById('editor-modal').classList.contains('hidden')) {
closeEditorModal();
return;
}
els.list.querySelectorAll('.action-menu[open]').forEach(menu => {
menu.removeAttribute('open');
menu.closest('.stream-row')?.classList.remove('menu-open');
});
});
els.list.addEventListener('toggle', (e) => {
if (!e.target.classList.contains('action-menu')) return;
const row = e.target.closest('.stream-row');
if (row) row.classList.toggle('menu-open', e.target.open);
if (!e.target.open) return;
els.list.querySelectorAll('.stream-row.menu-open').forEach(openRow => {
if (openRow !== row) openRow.classList.remove('menu-open');
});
}, true);
els.form.addEventListener('submit', async (e) => {
e.preventDefault();
const links = Array.from(els.linksContainer.children).map(row => ({
name: row.querySelector('.l-name').value,
type: row.querySelector('.l-type').value,
url: row.querySelector('.l-url').value,
key: row.querySelector('.l-key').value.trim(),
clearkey: row.querySelector('.l-clearkey').value.trim()
})).filter(l => l.name && l.url);
const payload = {
id: els.idInput.value,
eventName: els.nameInput.value,
streamLabel: els.streamLabelInput.value,
streamPassword: els.passInput.value,
isHidden: els.hiddenInput.checked,
links
};
try {
await apiCall(payload.id ? 'update' : 'add', payload);
closeEditorModal();
loadStreams();
} catch (e) {
alert(e.message);
}
});
const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '') => {
const div = document.createElement('div');
div.className = 'link-row';
div.innerHTML = `
<input class="l-name" placeholder="${t('ph.link_name')}" value="${escapeAttr(name)}">
<select class="l-type">
<option value="" ${type === '' ? 'selected' : ''}>${t('type.auto')}</option>
<option value="m3u8" ${type === 'm3u8' ? 'selected' : ''}>HLS</option>
<option value="flv" ${type === 'flv' ? 'selected' : ''}>FLV</option>
<option value="dash" ${type === 'dash' ? 'selected' : ''}>DASH</option>
</select>
<div class="url-field">
<input class="l-url" placeholder="${t('ph.link_url')}" value="${escapeAttr(url)}">
<div class="stream-check-status" aria-live="polite"></div>
</div>
<textarea class="l-key" rows="2" placeholder="${t('ph.key_aes')}">${escapeHtml(key)}</textarea>
<textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea>
<button type="button" class="btn-danger" onclick="this.parentElement.remove()">×</button>
`;
els.linksContainer.appendChild(div);
div.querySelector('.l-url').addEventListener('input', () => scheduleLinkRowCheck(div));
div.querySelector('.l-url').addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
div.querySelector('.l-type').addEventListener('change', () => scheduleLinkRowCheck(div, 0));
if (url) scheduleLinkRowCheck(div, 250);
};
document.getElementById('add-link-btn').onclick = () => addLinkUI();
els.cancelBtn.onclick = closeEditorModal;
document.getElementById('add-stream-btn').addEventListener('click', () => {
openEditorModal(null, activeRecordsLabel);
});
const _savedRtmpHost = localStorage.getItem('obs_rtmp_host');
els.obsRtmpHost.value = _savedRtmpHost !== null ? _savedRtmpHost : (localStorage.getItem('obs_public_host') || '');
els.obsPlaybackOrigin.value = localStorage.getItem('obs_playback_origin') || '';
els.obsRtmpHost.placeholder = (window.location.hostname || 'NAS-IP') + ':1935';
els.obsPlaybackOrigin.placeholder = `${window.location.protocol || 'http:'}//${window.location.hostname || 'NAS-IP'}:18088`;
els.obsRtmpHost.addEventListener('input', refreshObsUrls);
els.obsPlaybackOrigin.addEventListener('input', refreshObsUrls);
els.obsStreamKey.addEventListener('input', refreshObsUrls);
document.querySelectorAll('.obs-copy-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const target = document.getElementById(btn.dataset.copyTarget);
await copyText(target.value);
showStatus(els.obsStatus, t('msg.copied'));
});
});
els.obsRouteAddBtn.addEventListener('click', async () => {
const streamKey = els.obsRouteKey.value.trim();
if (!streamKey) {
showStatus(els.obsStatus, t('msg.no_key'), true);
return;
}
try {
await apiCall('add_obs_route', { streamKey });
els.obsRouteKey.value = '';
await loadObsRoutes();
showStatus(els.obsStatus, t('msg.route_added'));
} catch (e) {
showStatus(els.obsStatus, e.message, true);
}
});
els.obsRouteList.addEventListener('click', async (e) => {
if (e.target.classList.contains('obs-route-copy-btn')) {
await copyText(e.target.dataset.url || '');
showStatus(els.obsStatus, t('msg.copied'));
}
if (e.target.classList.contains('obs-route-use-btn')) {
const kind = e.target.dataset.kind || 'hls';
const type = kind === 'flv' ? 'flv' : 'm3u8';
const name = kind === 'flv' ? t('obs.hidden_flv') : t('obs.hidden_hls');
if (!els.nameInput.value.trim()) els.nameInput.value = `${t('obs.default_name')} ${e.target.dataset.key || ''}`.trim();
addLinkUI(name, e.target.dataset.url || '', '', '', type);
document.getElementById('editor-modal').classList.remove('hidden');
showStatus(els.obsStatus, t('obs.filled_in').replace('{name}', name));
}
if (e.target.classList.contains('obs-route-delete-btn')) {
if (!confirm(t('confirm.delete_route'))) return;
try {
await apiCall('delete_obs_route', { id: e.target.dataset.id });
await loadObsRoutes();
showStatus(els.obsStatus, t('msg.route_deleted'));
} catch (err) {
showStatus(els.obsStatus, err.message, true);
}
}
});
document.getElementById('use-obs-hls-btn').addEventListener('click', () => appendObsLink('hls'));
document.getElementById('use-obs-flv-btn').addEventListener('click', () => appendObsLink('flv'));
window.setInterval(checkFormLinks, probeIntervalMs);
window.setInterval(checkSavedStreams, probeIntervalMs);
function resetForm() {
els.form.reset();
els.idInput.value = '';
els.title.innerText = t('editor.add_title');
els.streamLabelInput.value = 'LIVE';
els.cancelBtn.classList.add('hidden');
els.linksContainer.innerHTML = '';
addLinkUI();
}
function openEditorModal(stream, defaultLabel) {
if (stream) {
els.title.innerText = t('editor.edit_title');
els.idInput.value = stream.id;
els.nameInput.value = stream.event_name;
els.streamLabelInput.value = String(stream.stream_label || 'LIVE').toUpperCase() === 'ARCHIVE' ? 'ARCHIVE' : 'LIVE';
els.passInput.value = stream.stream_password || '';
els.hiddenInput.checked = stream.is_hidden == 1;
els.cancelBtn.classList.remove('hidden');
els.linksContainer.innerHTML = '';
const links = JSON.parse(stream.links_json || '[]');
links.forEach(l => addLinkUI(l.name, l.url, l.key, l.clearkey, l.type));
if (links.length === 0) addLinkUI();
} else {
resetForm();
if (defaultLabel) {
els.streamLabelInput.value = String(defaultLabel).toUpperCase() === 'ARCHIVE' ? 'ARCHIVE' : 'LIVE';
}
}
document.getElementById('editor-modal').classList.remove('hidden');
}
function closeEditorModal() {
document.getElementById('editor-modal').classList.add('hidden');
resetForm();
}
document.getElementById('editor-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeEditorModal();
});
document.getElementById('editor-modal-close').addEventListener('click', closeEditorModal);
refreshObsUrls();
resetForm();
});
</script>
<!-- Editor modal -->
<div id="editor-modal" class="modal-overlay hidden">
<div class="modal-card modal-card-wide">
<div class="modal-head">
<h3 id="form-title" style="margin:0;font-size:1.05rem;font-weight:900;" data-i18n="editor.add_title">新增直播</h3>
<button type="button" id="editor-modal-close" class="modal-close-btn"></button>
</div>
<form id="stream-form">
<input type="hidden" id="stream-id">
<div class="form-grid">
<div>
<label data-i18n="form.stream_name">直播名称</label>
<input type="text" id="event-name" required>
</div>
<div>
<label data-i18n="form.stream_pw">访问密码 (留空则公开)</label>
<input type="text" id="stream-password">
</div>
<div>
<label data-i18n="form.stream_type">直播类型</label>
<select id="stream-label">
<option value="LIVE">LIVE</option>
<option value="ARCHIVE">ARCHIVE</option>
</select>
</div>
</div>
<div style="margin-top: 22px;">
<h3 data-i18n="h3.links">视角配置</h3>
<p class="hint" data-i18n="hint.links">填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息</p>
<div id="links-container"></div>
<button type="button" id="add-link-btn" class="btn-success" data-i18n="btn.add_link">+ 添加视角</button>
</div>
<label style="display: flex; align-items: center; gap: 10px; margin-top: 22px; cursor: pointer;">
<input type="checkbox" id="is-hidden" style="width: auto;">
<span data-i18n="form.hide_home">不在主页显示</span>
</label>
<div class="actions">
<button type="submit" id="save-btn" class="btn-primary" data-i18n="btn.save">保 存</button>
<button type="button" id="cancel-btn" class="btn-secondary hidden" data-i18n="btn.cancel">取 消</button>
</div>
</form>
</div>
</div>
<!-- Stream stats modal -->
<div id="stream-stat-modal" class="modal-overlay hidden">
<div class="modal-card modal-card-wide">
<div class="modal-head">
<h2 id="stream-stat-modal-title" style="font-size:1.05rem;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:480px;" data-i18n="stat.title">直播统计</h2>
<button type="button" id="stream-stat-modal-close" class="btn-secondary" style="padding:6px 10px;"></button>
</div>
<div class="stream-stat-summary-grid">
<div class="stream-stat-mini-card"><div class="stream-stat-mini-lbl" data-i18n="stat.online">当前在线</div><div class="stream-stat-mini-val" id="ss-online"></div></div>
<div class="stream-stat-mini-card"><div class="stream-stat-mini-lbl" data-i18n="stat.today">今日观看</div><div class="stream-stat-mini-val" id="ss-today"></div></div>
<div class="stream-stat-mini-card"><div class="stream-stat-mini-lbl" data-i18n="stat.total">历史总量</div><div class="stream-stat-mini-val" id="ss-total"></div></div>
<div class="stream-stat-mini-card"><div class="stream-stat-mini-lbl" data-i18n="stat.unique">独立访客</div><div class="stream-stat-mini-val" id="ss-unique"></div></div>
</div>
<div class="dash-two-col" style="margin-bottom:16px;">
<div>
<div style="font-weight:800;font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;" data-i18n="stat.device">设备分布</div>
<div class="dash-hbar-list">
<div class="dash-hbar-row"><span class="dash-hbar-icon">💻</span><span class="dash-hbar-lbl" data-i18n="device.desktop">桌面端</span><div class="dash-hbar-track"><div class="dash-hbar-fill" id="ss-dh-desktop" style="width:0%;background:var(--blue)"></div></div><span class="dash-hbar-pct" id="ss-dh-desktop-pct"></span></div>
<div class="dash-hbar-row"><span class="dash-hbar-icon">📱</span><span class="dash-hbar-lbl" data-i18n="device.mobile">移动端</span><div class="dash-hbar-track"><div class="dash-hbar-fill" id="ss-dh-mobile" style="width:0%;background:var(--mint)"></div></div><span class="dash-hbar-pct" id="ss-dh-mobile-pct"></span></div>
<div class="dash-hbar-row"><span class="dash-hbar-icon">📋</span><span class="dash-hbar-lbl" data-i18n="device.tablet">平板端</span><div class="dash-hbar-track"><div class="dash-hbar-fill" id="ss-dh-tablet" style="width:0%;background:var(--gold)"></div></div><span class="dash-hbar-pct" id="ss-dh-tablet-pct"></span></div>
</div>
</div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px;margin-bottom:8px;">
<div style="font-weight:800;font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;" data-i18n="stat.timeseries">时间分布</div>
<div class="dash-range-tabs" id="ss-range-tabs">
<button type="button" class="dash-range-btn" data-range="today" data-i18n="range.today">今日</button>
<button type="button" class="dash-range-btn active" data-range="7d" data-i18n="range.7d_short">7天</button>
<button type="button" class="dash-range-btn" data-range="30d" data-i18n="range.30d_short">30天</button>
</div>
</div>
<div class="dash-bar-chart" id="ss-bar-chart" style="height:90px;"></div>
<div class="dash-bar-axis" id="ss-bar-axis"></div>
</div>
</div>
<div class="dash-two-col" style="margin-bottom:16px;">
<div>
<div style="font-weight:800;font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;" data-i18n="stat.browser">浏览器分布</div>
<div class="dash-hbar-list" id="ss-browser-list"><div style="color:var(--muted);font-size:.82rem;" data-i18n="status.loading">加载中...</div></div>
</div>
<div>
<div style="font-weight:800;font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;" data-i18n="stat.os">操作系统分布</div>
<div class="dash-hbar-list" id="ss-os-list"><div style="color:var(--muted);font-size:.82rem;" data-i18n="status.loading">加载中...</div></div>
</div>
</div>
<div style="margin-bottom:16px;">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
<div style="font-weight:800;font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;" data-i18n="stat.geo">地理分布</div>
<div class="dash-range-tabs" id="ss-geo-range">
<button class="dash-range-btn" data-range="today" data-i18n="range.today">今日</button>
<button class="dash-range-btn active" data-range="30d" data-i18n="range.30d">近 30 天</button>
<button class="dash-range-btn" data-range="all" data-i18n="range.all">全部</button>
</div>
</div>
<div class="geo-map-canvas" id="ss-geo-map" style="height:240px;"></div>
<div class="geo-country-list" id="ss-geo-list"></div>
</div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="font-weight:800;font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;" data-i18n="stat.sessions">最近会话</div>
<div style="display:flex;align-items:center;gap:8px;">
<select id="ss-page-size" style="font-size:.78rem;color:var(--muted);background:var(--input);border:1px solid var(--line);border-radius:5px;padding:2px 6px;cursor:pointer;">
<option value="5" data-i18n="pgsz.5">5 条</option>
<option value="10" data-i18n="pgsz.10">10 条</option>
<option value="20" data-i18n="pgsz.20">20 条</option>
<option value="50" data-i18n="pgsz.50">50 条</option>
<option value="100" data-i18n="pgsz.100">100 条</option>
</select>
<span id="ss-live-indicator" class="live-badge" data-state="connecting">连接中...</span>
</div>
</div>
<div class="dash-range-tabs" id="ss-sort-tabs" style="margin-bottom:10px;flex-wrap:wrap;">
<button type="button" class="dash-range-btn active" data-sort-col="last_seen_at">最后活跃 ↓</button>
<button type="button" class="dash-range-btn" data-sort-col="started_at">开始时间</button>
<button type="button" class="dash-range-btn" data-sort-col="duration">时长</button>
<button type="button" class="dash-range-btn" data-sort-col="device_type">设备</button>
<button type="button" class="dash-range-btn" data-sort-col="browser">浏览器</button>
<button type="button" class="dash-range-btn" data-sort-col="ip_address">IP</button>
</div>
<div class="recent-sessions-list" id="ss-recent-list"><div style="color:var(--muted);text-align:center;padding:16px;" data-i18n="stat.loading">加载中...</div></div>
<div class="sessions-pagination" id="ss-sessions-pagination" style="display:none;">
<button type="button" id="ss-prev-page" class="btn-secondary" style="padding:6px 10px;" data-i18n="page.prev">上一页</button>
<span id="ss-page-info" class="page-info"></span>
<button type="button" id="ss-next-page" class="btn-secondary" style="padding:6px 10px;" data-i18n="page.next">下一页</button>
<span id="ss-total-count" class="page-info"></span>
</div>
</div>
</div>
</div>
<script>
// Dashboard & Stream Stats
// This is a separate <script> block from the main app block above.
// updateSortTabs() is defined inside this IIFE and registered as a
// _langChangeHooks callback from DOMContentLoaded below. It must live
// here (not in the main block) so the closure captures the correct
// local variables without cross-block ReferenceErrors.
(() => {
// Fetch helper for GET with extra query params (apiCall is scoped inside DOMContentLoaded)
const dashGet = async (action, extra = '') => {
const res = await fetch(`/api?action=${encodeURIComponent(action)}${extra}&_t=${Date.now()}`);
const data = await res.json();
if (!res.ok || data.status === 'error') {
const base = data.code ? (t('err.' + data.code) || data.code) : (data.message || 'API Error');
const detail = data.detail ? ` (${data.detail})` : '';
throw new Error(base + detail);
}
return data;
};
const fmtDur = s => {
if (!s || s <= 0) return '—';
s = Math.round(s);
if (s < 60) return s + 's';
if (s < 3600) return Math.round(s / 60) + 'm';
return (s / 3600).toFixed(1) + 'h';
};
const fmtRel = ts => {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return t('rel.just_now');
if (d < 3600) return t('rel.min_ago').replace('{n}', Math.floor(d / 60));
if (d < 86400) return t('rel.hour_ago').replace('{n}', Math.floor(d / 3600));
return t('rel.day_ago').replace('{n}', Math.floor(d / 86400));
};
const fmtN = n => {
if (n >= 10000) return (n / 10000).toFixed(1) + 'w';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
};
// ── Bar chart renderer ────────────────────────────────────────
function renderBarChart(chartEl, axisEl, buckets, range, refStart, bucketSecs) {
chartEl.innerHTML = '';
axisEl.innerHTML = '';
const maxVal = Math.max(...buckets, 1);
buckets.forEach((val, i) => {
const pct = Math.max(val / maxVal * 100, val > 0 ? 2 : 0);
let label = '', tip = '';
if (range === 'today') {
label = i % 6 === 0 ? i + 'h' : '';
tip = i + ':00 ' + val + ' ' + t('chart.unit');
} else {
const dt = new Date((refStart + i * bucketSecs) * 1000);
const m = dt.getMonth() + 1, d2 = dt.getDate();
if (range === '7d') {
if (LANG === 'zh') {
const days = ['日','一','二','三','四','五','六'];
label = '周' + days[dt.getDay()];
} else {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
label = days[dt.getDay()];
}
} else {
if (LANG === 'zh') {
label = i % 5 === 0 ? (i === 0 ? '30天前' : i === 29 ? '今天' : (29 - i) + '天前') : '';
} else {
label = i % 5 === 0 ? (i === 0 ? '-30d' : i === 29 ? 'today' : '-' + (29 - i) + 'd') : '';
}
}
tip = m + '/' + d2 + ' ' + val + ' ' + t('chart.unit');
}
const wrap = document.createElement('div'); wrap.className = 'dash-bar-col-wrap';
const tipEl = document.createElement('div'); tipEl.className = 'dash-bar-tip'; tipEl.textContent = tip;
const bar = document.createElement('div'); bar.className = 'dash-bar-col'; bar.style.height = pct + '%';
wrap.appendChild(tipEl); wrap.appendChild(bar);
chartEl.appendChild(wrap);
const lbl = document.createElement('div'); lbl.className = 'dash-bar-axis-lbl'; lbl.textContent = label;
axisEl.appendChild(lbl);
});
}
// ── Dashboard ─────────────────────────────────────────────────
let dashRange = localStorage.getItem('dash_ts_range') || 'today';
let dashEventSource = null;
function setDashLiveIndicator(state) {
const el = document.getElementById('dash-live-indicator');
if (!el) return;
el.dataset.state = state;
if (state === 'live') el.innerHTML = `<span class="live-dot"></span>${t('live.live')}`;
else if (state === 'error') el.innerHTML = `<span class="live-dot offline"></span>${t('live.disconnected')}`;
else el.innerHTML = t('stat.connecting');
}
function stopDashSSE() {
if (dashEventSource) { dashEventSource.close(); dashEventSource = null; }
}
function startDashSSE() {
stopDashSSE();
setDashLiveIndicator('connecting');
document.querySelectorAll('#dash-range-tabs .dash-range-btn').forEach(b => b.classList.toggle('active', b.dataset.range === dashRange));
loadDashTimeseries();
const es = new EventSource(`/api?action=stats_dashboard_realtime&_t=${Date.now()}`);
dashEventSource = es;
es.onmessage = e => {
try {
const d = JSON.parse(e.data);
renderDashOverview(d.overview);
renderDashStreams(d.streams);
setDashLiveIndicator('live');
if (!dashGeoInitDone) { dashGeoInitDone = true; renderGeoSection('dash-geo-map', 'dash-geo-list', 'dash-geo-range', null); }
} catch (err) { console.warn('Dashboard SSE error', err); }
};
es.onerror = () => setDashLiveIndicator('error');
}
async function loadDashTimeseries() {
try { renderDashTimeseries((await dashGet('stats_timeseries', `&range=${dashRange}`)).data); } catch (e) { console.warn(e); }
}
const BROWSER_COLORS = { Chrome:'var(--blue)', Edge:'var(--mint)', Safari:'var(--gold)', Firefox:'var(--rose)', Opera:'#a78bfa', Other:'var(--muted)' };
const OS_COLORS = { Windows:'var(--blue)', macOS:'var(--mint)', iOS:'var(--gold)', Android:'var(--rose)', Linux:'#a78bfa', Other:'var(--muted)' };
function renderHbarDynamic(listId, items, colorMap) {
const el = document.getElementById(listId);
if (!el) return;
if (!items || !items.length) { el.innerHTML = `<div style="color:var(--muted);font-size:.82rem;">${t('hbar.no_data')}</div>`; return; }
const total = items.reduce((s, r) => s + r.cnt, 0) || 1;
el.innerHTML = items.map(r => {
const pct = Math.round(r.cnt / total * 100);
const color = colorMap[r.name] || 'var(--muted)';
return `<div class="dash-hbar-row">
<span class="dash-hbar-lbl" style="width:72px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${_esc(r.name)}</span>
<div class="dash-hbar-track"><div class="dash-hbar-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="dash-hbar-pct">${pct}%</span>
</div>`;
}).join('');
}
function renderDashOverview(d) {
if (!d) return;
document.getElementById('dash-online').textContent = fmtN(d.total_online);
document.getElementById('dash-today').textContent = fmtN(d.today_views);
document.getElementById('dash-total').textContent = fmtN(d.total_views);
document.getElementById('dash-unique').textContent = fmtN(d.unique_visitors);
const dev = d.devices || {};
const desktop = dev['desktop'] || 0, mobile = dev['mobile'] || 0, tablet = dev['tablet'] || 0;
const tot = desktop + mobile + tablet || 1;
const pct = n => Math.round(n / tot * 100);
[['dh-desktop', desktop], ['dh-mobile', mobile], ['dh-tablet', tablet]].forEach(([id, n]) => {
const el = document.getElementById(id); if (el) el.style.width = pct(n) + '%';
const el2 = document.getElementById(id + '-pct'); if (el2) el2.textContent = pct(n) + '%';
});
renderHbarDynamic('dash-browser-list', d.browsers, BROWSER_COLORS);
renderHbarDynamic('dash-os-list', d.oses, OS_COLORS);
}
function renderDashTimeseries(d) {
if (!d) return;
const titleEl = document.getElementById('dash-chart-title');
if (titleEl) titleEl.textContent = t('chart.' + d.range) || t('section.timeseries');
renderBarChart(document.getElementById('dash-bar-chart'), document.getElementById('dash-bar-axis'), d.buckets, d.range, d.ref_start, d.bucket_secs);
}
let dashStreamsSortCol = 'last_seen_at';
let dashStreamsSortDir = 'desc';
let dashStreamsData = [];
function sortDashStreams(data) {
const col = dashStreamsSortCol;
const dir = dashStreamsSortDir === 'asc' ? 1 : -1;
return [...data].sort((a, b) => {
const av = a[col], bv = b[col];
if (typeof av === 'string') return dir * String(av || '').localeCompare(String(bv || ''));
return dir * ((av || 0) - (bv || 0));
});
}
function updateDashStreamHeaders() {
document.querySelectorAll('.dash-table thead th[data-sort-col]').forEach(th => {
const col = th.dataset.sortCol;
th.classList.toggle('sort-asc', col === dashStreamsSortCol && dashStreamsSortDir === 'asc');
th.classList.toggle('sort-desc', col === dashStreamsSortCol && dashStreamsSortDir === 'desc');
});
}
function renderDashStreams(rows) {
dashStreamsData = rows || [];
const tbody = document.getElementById('dash-streams-tbody');
if (!tbody) return;
if (!dashStreamsData.length) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">${t('table.no_data')}</td></tr>`;
updateDashStreamHeaders();
return;
}
const sorted = sortDashStreams(dashStreamsData);
const maxTotal = Math.max(...sorted.map(r => r.total_views), 1);
const esc2 = s => String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
tbody.innerHTML = sorted.map(r => {
const barPct = Math.round(r.total_views / maxTotal * 100);
const devTotal = r.mobile + r.tablet + r.desktop || 0;
let devHtml = devTotal > 0
? (r.desktop > 0 ? `<span class="dash-dev-badge desktop">💻 ${Math.round(r.desktop/devTotal*100)}%</span>` : '')
+ (r.mobile > 0 ? `<span class="dash-dev-badge mobile">📱 ${Math.round(r.mobile/devTotal*100)}%</span>` : '')
+ (r.tablet > 0 ? `<span class="dash-dev-badge tablet">📋 ${Math.round(r.tablet/devTotal*100)}%</span>` : '')
: '<span class="dash-muted">—</span>';
const onlineCls = r.online > 0 ? ' dash-td-online' : '';
return `<tr>
<td class="dash-td-name dash-td-name-link" data-id="${r.stream_id}" data-name="${esc2(r.event_name)}" title="${esc2(r.event_name)}">${esc2(r.event_name) || `<span class="dash-muted">${t('table.deleted')}</span>`}</td>
<td class="dash-td-num${onlineCls}">${r.online > 0 ? '● ' + r.online : '—'}</td>
<td class="dash-td-num">${r.today_views}</td>
<td><div class="dash-td-bar"><span class="dash-td-bar-num">${r.total_views}</span><div class="dash-td-bar-track"><div class="dash-td-bar-fill" style="width:${barPct}%"></div></div></div></td>
<td class="dash-td-num">${r.unique_visitors}</td>
<td class="dash-td-num dash-muted">${fmtDur(r.avg_duration)}</td>
<td><div class="dash-dev-badges">${devHtml}</div></td>
<td class="dash-td-num dash-muted">${fmtRel(r.last_seen_at)}</td>
</tr>`;
}).join('');
updateDashStreamHeaders();
}
document.querySelector('.dash-table')?.addEventListener('click', e => {
const th = e.target.closest('th[data-sort-col]');
if (!th) return;
const col = th.dataset.sortCol;
if (col === dashStreamsSortCol) {
dashStreamsSortDir = dashStreamsSortDir === 'desc' ? 'asc' : 'desc';
} else {
dashStreamsSortCol = col;
dashStreamsSortDir = col === 'event_name' ? 'asc' : 'desc';
}
renderDashStreams(dashStreamsData);
});
// ── Stream stat modal ──────────────────────────────────────────
let currentStatId = null;
let statEventSource = null;
const statModal = document.getElementById('stream-stat-modal');
const _esc = s => String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function setLiveIndicator(state) {
const el = document.getElementById('ss-live-indicator');
if (!el) return;
el.dataset.state = state;
if (state === 'live') el.innerHTML = `<span class="live-dot"></span>${t('live.live')}`;
else if (state === 'error') el.innerHTML = `<span class="live-dot offline"></span>${t('live.disconnected')}`;
else el.innerHTML = t('stat.connecting');
}
function stopStreamSSE() {
if (statEventSource) { statEventSource.close(); statEventSource = null; }
}
function startStreamSSE(id) {
stopStreamSSE();
setLiveIndicator('connecting');
const es = new EventSource(`/api?action=stats_stream_realtime&id=${encodeURIComponent(id)}`);
statEventSource = es;
es.onmessage = e => {
try {
const d = JSON.parse(e.data);
renderStreamSummary(d.summary);
if (ssPageStreamId) loadSessionsPage(ssPageStreamId, ssPageOffset);
setLiveIndicator('live');
} catch (err) { console.warn('SSE parse error', err); }
};
es.onerror = () => setLiveIndicator('error');
}
function renderStreamSummary(s) {
document.getElementById('ss-online').textContent = fmtN(s.online);
document.getElementById('ss-today').textContent = fmtN(s.today_views);
document.getElementById('ss-total').textContent = fmtN(s.total_views);
document.getElementById('ss-unique').textContent = fmtN(s.unique_visitors);
const devTot = s.mobile + s.tablet + s.desktop || 1;
const dp = n => Math.round(n / devTot * 100);
[['ss-dh-desktop', s.desktop], ['ss-dh-mobile', s.mobile], ['ss-dh-tablet', s.tablet]].forEach(([id2, n]) => {
const el = document.getElementById(id2); if (el) el.style.width = dp(n) + '%';
const el2 = document.getElementById(id2 + '-pct'); if (el2) el2.textContent = dp(n) + '%';
});
renderHbarDynamic('ss-browser-list', s.browsers, BROWSER_COLORS);
renderHbarDynamic('ss-os-list', s.oses, OS_COLORS);
}
const devIcon = t => t === 'mobile' ? '📱' : t === 'tablet' ? '📋' : '💻';
const bClass = b => ({Chrome:'chrome', Edge:'edge', Safari:'safari', Firefox:'firefox', Opera:'opera'}[b] || 'other');
const fmtGeo = geo => {
if (!geo) return '';
const parts = [geo.country, geo.city && geo.city !== geo.region ? geo.city : geo.region].filter(Boolean);
return parts.join(' · ');
};
function renderRecentSessions(sessions) {
const recentEl = document.getElementById('ss-recent-list');
if (!sessions || !sessions.length) {
recentEl.innerHTML = `<div style="color:var(--muted);text-align:center;padding:16px;">${t('stat.no_records')}</div>`;
return;
}
const nowSec = Math.floor(Date.now() / 1000);
recentEl.innerHTML = sessions.map(row => {
const trulyActive = row.is_active && (nowSec - row.last_seen_at < 75);
let dur, durLabel;
if (row.ended_at > 0) {
dur = fmtDur(row.ended_at - row.started_at);
durLabel = t('sess.dur') + ' ' + dur;
} else if (trulyActive) {
dur = t('sess.watching') + ' · ' + fmtDur(nowSec - row.started_at);
durLabel = dur;
} else {
const elapsed = row.last_seen_at - row.started_at;
dur = elapsed > 0 ? fmtDur(elapsed) : t('sess.lt1min');
durLabel = t('sess.dur') + ' ' + dur;
}
const dt = new Date(row.started_at * 1000);
const ts2 = dt.toLocaleString(LANG === 'zh' ? 'zh-CN' : 'en-US', {month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit'});
const browser = row.browser || 'Other';
const ip = row.ip_address || '';
const geo = fmtGeo(row.geo);
return `<div class="recent-session-row">
<div class="rsr-top">
<span style="font-size:.88rem;">${devIcon(row.device_type)}</span>
<span class="browser-badge ${bClass(browser)}">${_esc(browser)}</span>
<span class="rsr-ip" title="${_esc(ip)}">${_esc(ip) || '<span style="color:var(--muted)">—</span>'}</span>
${trulyActive ? `<span class="badge badge-tg" style="font-size:.63rem;padding:1px 5px;flex-shrink:0;">${t('sess.online')}</span>` : ''}
<span class="rsr-time">${ts2}</span>
</div>
<div class="rsr-bot">
<span class="rsr-geo">${geo ? '🌐 ' + _esc(geo) : ''}</span>
<span class="rsr-dur">${durLabel}</span>
</div>
</div>`;
}).join('');
}
let ssPageStreamId = null;
let ssPageOffset = 0;
let ssPageLimit = parseInt(localStorage.getItem('ss_page_limit') || '20', 10);
let ssPageOrderBy = 'last_seen_at';
let ssPageOrderDir = 'desc';
const SORT_COL_KEY = { last_seen_at: 'sort.last_seen', started_at: 'sort.started_at', duration: 'sort.duration', device_type: 'sort.device', browser: 'sort.browser', ip_address: 'sort.ip' };
function updateSortTabs() {
document.querySelectorAll('#ss-sort-tabs .dash-range-btn').forEach(btn => {
const col = btn.dataset.sortCol;
const active = col === ssPageOrderBy;
btn.classList.toggle('active', active);
const label = t(SORT_COL_KEY[col] || col);
btn.textContent = active ? label + ' ' + (ssPageOrderDir === 'asc' ? '↑' : '↓') : label;
});
}
async function loadSessionsPage(streamId, offset) {
ssPageStreamId = streamId;
ssPageOffset = offset;
try {
const res = await fetch(`/api?action=stats_sessions_page&id=${streamId}&offset=${offset}&limit=${ssPageLimit}&order_by=${ssPageOrderBy}&order_dir=${ssPageOrderDir}&_t=${Date.now()}`);
const json = await res.json();
const { sessions, total } = json.data;
renderRecentSessions(sessions);
const totalPages = Math.max(1, Math.ceil(total / ssPageLimit));
const curPage = Math.floor(offset / ssPageLimit) + 1;
document.getElementById('ss-page-info').textContent = totalPages > 1 ? t('page.page_of').replace('{cur}', curPage).replace('{total}', totalPages) : '';
document.getElementById('ss-total-count').textContent = t('page.total').replace('{n}', total);
document.getElementById('ss-prev-page').disabled = offset <= 0;
document.getElementById('ss-next-page').disabled = offset + ssPageLimit >= total;
document.getElementById('ss-sessions-pagination').style.display = total > 0 ? 'flex' : 'none';
} catch (e) { console.warn('loadSessionsPage error', e); }
}
let dashGeoInitDone = false;
async function renderGeoSection(canvasId, listId, rangeTabsId, streamId) {
const echartsOk = await window.__echartsReady;
const rangeEl = document.getElementById(rangeTabsId);
const listEl = document.getElementById(listId);
const canvas = document.getElementById(canvasId);
let geoChart = null;
const fetchAndRender = async (range) => {
const url = `/api?action=stats_geo${streamId ? '&id=' + streamId : ''}&range=${range}&_t=${Date.now()}`;
let countries = [];
try {
const res = await fetch(url);
const data = await res.json();
countries = data?.data?.countries || [];
} catch (e) {}
renderGeoList(listEl, countries);
if (echartsOk && canvas) await renderGeoMap(canvas, countries, geoChart, c => { geoChart = c; });
};
const storageKey = 'geo_range_' + rangeTabsId;
const initialRange = localStorage.getItem(storageKey) || '30d';
if (rangeEl) {
const fresh = rangeEl.cloneNode(true);
rangeEl.parentNode.replaceChild(fresh, rangeEl);
fresh.querySelectorAll('.dash-range-btn').forEach(b =>
b.classList.toggle('active', b.dataset.range === initialRange));
fresh.querySelectorAll('.dash-range-btn').forEach(btn => {
btn.addEventListener('click', () => {
fresh.querySelectorAll('.dash-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
localStorage.setItem(storageKey, btn.dataset.range);
fetchAndRender(btn.dataset.range);
});
});
}
fetchAndRender(initialRange);
}
async function renderGeoMap(canvas, countries, existingChart, setChart) {
if (!window.__worldGeoJson) {
try {
const r = await fetch('https://cdn.jsdelivr.net/npm/echarts/map/json/world.json');
window.__worldGeoJson = await r.json();
} catch (e) {
try {
const r = await fetch('/world.json');
window.__worldGeoJson = await r.json();
} catch (e2) { return; }
}
echarts.registerMap('world', window.__worldGeoJson);
}
const chart = existingChart || echarts.getInstanceByDom(canvas) || echarts.init(canvas, null, { renderer: 'canvas' });
setChart(chart);
const max = countries[0]?.count || 1;
const datas = countries.map(c => ({ name: c.name, value: c.count }));
chart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: p => p.data ? `${p.name}: ${p.data.value}` : p.name },
visualMap: { min: 0, max, show: false, inRange: { color: ['rgba(78,126,232,0.08)', 'rgba(78,126,232,0.85)'] } },
series: [{ type: 'map', map: 'world', roam: true,
itemStyle: { borderColor: 'rgba(0,0,0,0.1)', borderWidth: 0.5 },
emphasis: { label: { show: false }, itemStyle: { areaColor: 'var(--mint)' } },
data: datas }]
}, true);
new MutationObserver(() => chart.resize()).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
function renderGeoList(listEl, countries) {
if (!listEl) return;
if (!countries.length) { listEl.innerHTML = `<p style="color:var(--muted);font-size:.82rem;margin:8px 0">${t('geo.no_data')}</p>`; return; }
const max = countries[0].count;
listEl.innerHTML = countries.slice(0, 15).map(c => `
<div class="geo-country-row">
<span>${_esc(c.name)}</span>
<span style="font-weight:800">${c.count}</span>
<div class="geo-bar-track"><div class="geo-bar-fill" style="width:${Math.round(100 * c.count / max)}%"></div></div>
</div>`).join('');
}
function openStreamStatModal(id, name) {
currentStatId = id;
const titleEl = document.getElementById('stream-stat-modal-title');
if (titleEl) titleEl.textContent = name || t('stat.title');
['ss-online','ss-today','ss-total','ss-unique'].forEach(k => { const el = document.getElementById(k); if (el) el.textContent = '—'; });
document.getElementById('ss-recent-list').innerHTML = `<div style="color:var(--muted);text-align:center;padding:16px;">${t('stat.loading')}</div>`;
const savedModalRange = localStorage.getItem('ss_ts_range') || '7d';
document.querySelectorAll('#ss-range-tabs .dash-range-btn').forEach(b => b.classList.toggle('active', b.dataset.range === savedModalRange));
statModal.classList.remove('hidden');
loadStreamTimeseries(id, savedModalRange);
startStreamSSE(id);
ssPageOffset = 0;
updateSortTabs();
loadSessionsPage(id, 0);
renderGeoSection('ss-geo-map', 'ss-geo-list', 'ss-geo-range', id);
}
async function loadStreamTimeseries(id, range) {
try {
const r = await dashGet('stats_stream_detail', `&id=${encodeURIComponent(id)}&range=${encodeURIComponent(range)}`);
const ts = r.data.timeseries;
renderBarChart(document.getElementById('ss-bar-chart'), document.getElementById('ss-bar-axis'), ts.buckets, ts.range, ts.ref_start, ts.bucket_secs);
} catch (e) { console.warn('loadStreamTimeseries error:', e); }
}
// ── DOMContentLoaded wiring ────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// updateSortTabs is in scope here; register it as a lang-change hook
_langChangeHooks.push(() => updateSortTabs());
// Dashboard menu button triggers
document.querySelectorAll('.admin-menu-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.adminViewTarget === 'dashboard') startDashSSE();
else stopDashSSE();
});
});
document.querySelectorAll('.admin-sub-btn').forEach(btn => {
btn.addEventListener('click', () => stopDashSSE());
});
// Check initial view
const initView = (location.hash || '').replace(/^#/, '') || localStorage.getItem('admin_active_view') || 'dashboard';
if (initView === 'dashboard') startDashSSE();
// Re-start SSE after login (enterPanel dispatches this event)
document.addEventListener('admin:enter-dashboard', () => startDashSSE());
// Dashboard range tabs
document.getElementById('dash-range-tabs')?.addEventListener('click', async e => {
const btn = e.target.closest('.dash-range-btn');
if (!btn) return;
document.querySelectorAll('#dash-range-tabs .dash-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
dashRange = btn.dataset.range;
localStorage.setItem('dash_ts_range', dashRange);
loadDashTimeseries();
});
// Dashboard export CSV
document.getElementById('dash-export-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('dash-export-btn');
btn.disabled = true; btn.textContent = t('dash.exporting');
try {
const res = await fetch(`/api?action=stats_export_csv&_t=${Date.now()}`);
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob(), url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `viewer_stats_${new Date().toISOString().slice(0,10)}.csv`;
a.click(); URL.revokeObjectURL(url);
} catch (e) { alert(e.message); }
finally { btn.disabled = false; btn.textContent = t('dash.export'); }
});
// Modal range tabs
document.getElementById('ss-range-tabs')?.addEventListener('click', e => {
const btn = e.target.closest('.dash-range-btn');
if (!btn || !currentStatId) return;
document.querySelectorAll('#ss-range-tabs .dash-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
localStorage.setItem('ss_ts_range', btn.dataset.range);
loadStreamTimeseries(currentStatId, btn.dataset.range);
});
// Close modal
const closeStatModal = () => { statModal.classList.add('hidden'); stopStreamSSE(); setLiveIndicator('connecting'); };
document.getElementById('stream-stat-modal-close')?.addEventListener('click', closeStatModal);
statModal?.addEventListener('click', e => { if (e.target === statModal) closeStatModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !statModal.classList.contains('hidden')) closeStatModal(); });
// Dashboard table stream name click
document.getElementById('dash-streams-tbody')?.addEventListener('click', e => {
const td = e.target.closest('.dash-td-name-link');
if (!td) return;
openStreamStatModal(td.dataset.id, td.dataset.name || '');
});
// stream-stat-btn delegation
document.getElementById('admin-stream-list')?.addEventListener('click', e => {
if (!e.target.classList.contains('stream-stat-btn')) return;
const id = e.target.dataset.id, name = e.target.dataset.name || '';
if (!id) return;
e.target.closest('.action-menu')?.removeAttribute('open');
openStreamStatModal(id, name);
});
// Session sort tabs
document.getElementById('ss-sort-tabs')?.addEventListener('click', e => {
const btn = e.target.closest('.dash-range-btn');
if (!btn || !btn.dataset.sortCol) return;
const col = btn.dataset.sortCol;
if (col === ssPageOrderBy) {
ssPageOrderDir = ssPageOrderDir === 'desc' ? 'asc' : 'desc';
} else {
ssPageOrderBy = col;
ssPageOrderDir = 'desc';
}
updateSortTabs();
if (ssPageStreamId) loadSessionsPage(ssPageStreamId, 0);
});
// Session pagination
document.getElementById('ss-prev-page')?.addEventListener('click', () =>
loadSessionsPage(ssPageStreamId, Math.max(0, ssPageOffset - ssPageLimit)));
document.getElementById('ss-next-page')?.addEventListener('click', () =>
loadSessionsPage(ssPageStreamId, ssPageOffset + ssPageLimit));
const pageSizeEl = document.getElementById('ss-page-size');
if (pageSizeEl) {
pageSizeEl.value = String(ssPageLimit);
if (!pageSizeEl.value) { pageSizeEl.value = '20'; ssPageLimit = 20; }
pageSizeEl.addEventListener('change', () => {
ssPageLimit = parseInt(pageSizeEl.value, 10);
localStorage.setItem('ss_page_limit', ssPageLimit);
if (ssPageStreamId) loadSessionsPage(ssPageStreamId, 0);
});
}
});
})();
</script>
</body>
</html>