Files
Henry_Du 5e466f4553 fix(ui): 补齐 generator.html 和 listmanager.html 的问号剥离兜底
- generator.html: 新增 ds() 钩子,在 renderList / batch 预览 / showToast 三个渲染点统一问号剥离
- listmanager.html: 新增 ds() 钩子,在 renderList / renderSequence / 自定义 select 选项 / showToast 四个点统一问号剥离
- APP_VERSION_TEXT bump 到 v1.0.107
2026-08-17 17:32:01 +08:00

270 lines
7.9 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Station Broadcast Streams</title>
<style>
:root {
--bg: #0b1220;
--panel: #111a2e;
--border: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.65);
--good: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
--link: #60a5fa;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
header .meta {
font-size: 12px;
color: var(--muted);
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
main { padding: 16px 20px 28px; }
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.toolbar input {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
width: 280px;
max-width: 100%;
outline: none;
}
.toolbar button, .toolbar a {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
font-size: 12px;
}
.toolbar button:hover, .toolbar a:hover { border-color: rgba(96,165,250,0.6); }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
vertical-align: top;
}
th {
text-align: left;
color: var(--muted);
font-weight: 600;
background: rgba(255,255,255,0.02);
position: sticky;
top: 0;
z-index: 1;
}
tr:hover td { background: rgba(96,165,250,0.06); }
.mono { font-family: var(--mono); }
.status {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 2px rgba(255,255,255,0.08) inset;
}
.dot.idle { background: var(--muted); }
.dot.pushing { background: var(--good); box-shadow: 0 0 12px rgba(34,197,94,0.35); }
.dot.error { background: var(--bad); box-shadow: 0 0 12px rgba(239,68,68,0.35); }
a.link { color: var(--link); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
.err {
color: rgba(255,255,255,0.8);
max-width: 520px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<header>
<h1>Station Broadcast Streams</h1>
<div class="meta">
<span id="meta-mode"></span>
<span id="meta-base"></span>
<span id="meta-updated"></span>
</div>
</header>
<main>
<div class="toolbar">
<input id="filter" placeholder="筛选:站名 / 站号 / 流名" />
<button id="btn-refresh">刷新</button>
<a href="/" target="_blank" rel="noreferrer">控制台</a>
</div>
<table>
<thead>
<tr>
<th style="width: 120px;">流名</th>
<th style="width: 220px;">车站</th>
<th style="width: 110px;">站号</th>
<th>播放地址</th>
<th style="width: 150px;">状态</th>
<th style="width: 210px;">最近时间</th>
<th style="width: 340px;">错误</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</main>
<script>
const tbody = document.getElementById('tbody');
const filterInput = document.getElementById('filter');
const metaMode = document.getElementById('meta-mode');
const metaBase = document.getElementById('meta-base');
const metaUpdated = document.getElementById('meta-updated');
let lastData = null;
function fmtTime(ts) {
if (!ts) return '-';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString();
}
function pickDot(status, lastError) {
if (status === 'pushing' || status === 'building') return 'pushing';
if (lastError) return 'error';
return 'idle';
}
function sanitizeDisplay(s) {
let v = String(s ?? '')
.replace(/[?¿?]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return v === '' ? '-' : v;
}
function escapeHtml(s) {
let v = sanitizeDisplay(s);
return v
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function render(data) {
lastData = data;
metaMode.textContent = `Mode: ${data.streamMode || '-'}`;
metaBase.textContent = `Base: ${data.publicBase || '-'}`;
metaUpdated.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
const q = filterInput.value.trim().toLowerCase();
const rows = (data.streams || []).filter(s => {
if (!q) return true;
return String(s.streamName || '').toLowerCase().includes(q)
|| String(s.stationName || '').toLowerCase().includes(q)
|| String(s.stationCode || '').toLowerCase().includes(q)
|| String(s.hlsUrl || '').toLowerCase().includes(q);
});
tbody.innerHTML = rows.map(s => {
const dot = pickDot(s.status, s.lastError);
const lastTime = s.lastStartAt || s.lastEndAt;
return `
<tr>
<td class="mono">${escapeHtml(s.streamName || '-')}</td>
<td>${escapeHtml(s.stationName || '-')}</td>
<td class="mono">${escapeHtml(s.stationCode || '-')}</td>
<td class="mono">
<a class="link" href="${escapeHtml(s.hlsUrl || '#')}" target="_blank" rel="noreferrer">${escapeHtml(s.hlsUrl || '-')}</a>
</td>
<td>
<span class="status">
<span class="dot ${dot}"></span>
<span class="pill">${escapeHtml(s.status || 'idle')}</span>
</span>
</td>
<td>
<div>Start: ${escapeHtml(fmtTime(s.lastStartAt))}</div>
<div>End: ${escapeHtml(fmtTime(s.lastEndAt))}</div>
</td>
<td class="mono err" title="${escapeHtml(s.lastError || '')}">${escapeHtml(s.lastError || '-')}</td>
</tr>
`;
}).join('');
}
async function refresh() {
try {
const res = await fetch('/api/streams', { cache: 'no-store' });
const data = await res.json();
render(data);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="7" class="mono">Failed to load /api/streams: ${escapeHtml(e?.message || e)}</td></tr>`;
}
}
document.getElementById('btn-refresh').addEventListener('click', refresh);
filterInput.addEventListener('input', () => { if (lastData) render(lastData); });
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>