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

406 lines
12 KiB
HTML
Raw Permalink 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>Stream Manager</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;
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;
flex-wrap: wrap;
}
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: 320px;
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); }
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;
}
.btn-danger { border-color: rgba(239,68,68,0.4); color: rgba(239,68,68,0.95); }
.btn-danger:hover { border-color: rgba(239,68,68,0.9); }
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 10;
}
.modal.open { display: flex; }
.modal-card {
width: 100%;
max-width: 520px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title { font-weight: 600; font-size: 14px; }
.modal-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.modal-body label { font-size: 12px; color: var(--muted); }
.modal-body input {
background: rgba(0,0,0,0.25);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 10px;
border-radius: 8px;
outline: none;
font-family: var(--mono);
}
.modal-footer {
padding: 12px 14px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
</head>
<body>
<header>
<h1>Stream Manager</h1>
<div class="meta">
<span id="meta-base"></span>
<span id="meta-updated"></span>
</div>
</header>
<main>
<div class="toolbar">
<input id="filter" placeholder="筛选:站名 / 站号 / 流名" />
<button id="btn-add">新增站点</button>
<button id="btn-refresh">刷新</button>
<a href="/" target="_blank" rel="noreferrer">控制台</a>
</div>
<table>
<thead>
<tr>
<th style="width: 150px;">流名</th>
<th style="width: 260px;">车站</th>
<th style="width: 140px;">站号</th>
<th>播放地址(m3u8)</th>
<th style="width: 180px;">操作</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</main>
<div class="modal" id="modal">
<div class="modal-card">
<div class="modal-header">
<div class="modal-title" id="modal-title">站点</div>
<button id="modal-close">关闭</button>
</div>
<div class="modal-body">
<div style="display:flex; flex-direction:column; gap:6px;">
<label>站名(唯一键)</label>
<input id="input-name" placeholder="例如:HC-02 站台 / Tokyo Station" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label>站号(会影响流名)</label>
<input id="input-code" placeholder="例如:HC-02_2" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label>重命名(可选,仅编辑时生效)</label>
<input id="input-newname" placeholder="不填则不改站名" />
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel">取消</button>
<button id="btn-save">保存</button>
</div>
</div>
</div>
<script>
const tbody = document.getElementById('tbody');
const filterInput = document.getElementById('filter');
const metaBase = document.getElementById('meta-base');
const metaUpdated = document.getElementById('meta-updated');
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const inputName = document.getElementById('input-name');
const inputCode = document.getElementById('input-code');
const inputNewName = document.getElementById('input-newname');
let mode = 'create';
let editKey = '';
let lastData = null;
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 openModal(nextMode, key, row) {
mode = nextMode;
editKey = key || '';
if (mode === 'create') {
modalTitle.textContent = '新增站点';
inputName.disabled = false;
inputName.value = '';
inputCode.value = '';
inputNewName.value = '';
} else {
modalTitle.textContent = '编辑站点';
inputName.disabled = true;
inputName.value = row?.stationName || '';
inputCode.value = row?.stationCode || '';
inputNewName.value = '';
}
modal.classList.add('open');
}
function closeModal() {
modal.classList.remove('open');
}
function render(data) {
lastData = data;
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 hasStation = !!s.stationName;
const actions = hasStation
? `
<button onclick="editStation('${escapeHtml(s.stationName)}')">编辑</button>
<button class="btn-danger" onclick="deleteStation('${escapeHtml(s.stationName)}')">删除站点</button>
`
: `
<button class="btn-danger" onclick="deleteStream('${escapeHtml(s.streamName)}')">清理流</button>
`;
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 style="white-space:nowrap">${actions}</td>
</tr>
`;
}).join('');
}
async function refresh() {
const res = await fetch('/api/streams', { cache: 'no-store' });
const data = await res.json();
render(data);
}
async function editStation(stationName) {
const row = (lastData?.streams || []).find(s => s.stationName === stationName);
openModal('edit', stationName, row);
}
async function deleteStation(stationName) {
if (!confirm(`删除站点:${stationName} ?`)) return;
await fetch(`/api/stations/${encodeURIComponent(stationName)}`, { method: 'DELETE' });
await refresh();
}
async function deleteStream(streamName) {
if (!confirm(`清理流状态:${streamName} ?`)) return;
await fetch(`/api/streams/${encodeURIComponent(streamName)}`, { method: 'DELETE' });
await refresh();
}
async function saveModal() {
const name = inputName.value.trim();
const code = inputCode.value.trim();
const newName = inputNewName.value.trim();
if (mode === 'create') {
if (!name) { alert('站名不能为空'); return; }
const res = await fetch('/api/stations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, code })
});
const data = await res.json();
if (!data.success) { alert(data.message || '保存失败'); return; }
} else {
const res = await fetch(`/api/stations/${encodeURIComponent(editKey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, newName: newName || undefined })
});
const data = await res.json();
if (!data.success) { alert(data.message || '保存失败'); return; }
}
closeModal();
await refresh();
}
document.getElementById('btn-add').addEventListener('click', () => openModal('create'));
document.getElementById('btn-refresh').addEventListener('click', refresh);
filterInput.addEventListener('input', () => { if (lastData) render(lastData); });
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('btn-cancel').addEventListener('click', closeModal);
document.getElementById('btn-save').addEventListener('click', saveModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>