- generator.html: 新增 ds() 钩子,在 renderList / batch 预览 / showToast 三个渲染点统一问号剥离 - listmanager.html: 新增 ds() 钩子,在 renderList / renderSequence / 自定义 select 选项 / showToast 四个点统一问号剥离 - APP_VERSION_TEXT bump 到 v1.0.107
406 lines
12 KiB
HTML
406 lines
12 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>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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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>
|