Merge stations with the same station code and platform into one logical target, and expand music and temp broadcast playback to all online slave nodes while the console and picker UIs show grouped station entries with node counts.
391 lines
15 KiB
HTML
391 lines
15 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>临时广播</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-body: #09090b;
|
|
--bg-panel: #18181b;
|
|
--bg-hover: #27272a;
|
|
--bg-border: #27272a;
|
|
--text-primary: #e4e4e7;
|
|
--text-muted: #71717a;
|
|
--accent: #fafafa;
|
|
--danger: #ef4444;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--radius: 6px;
|
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
padding: 24px;
|
|
background: var(--bg-body);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-ui);
|
|
}
|
|
.topbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
}
|
|
.btn {
|
|
border: 1px solid var(--bg-border);
|
|
background: transparent;
|
|
color: var(--text-primary);
|
|
padding: 8px 10px;
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
}
|
|
.btn:hover { background: var(--bg-hover); }
|
|
.btn-primary {
|
|
background: var(--accent);
|
|
color: #09090b;
|
|
border-color: var(--accent);
|
|
}
|
|
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
|
|
.btn-danger {
|
|
background: var(--danger);
|
|
border-color: var(--danger);
|
|
color: white;
|
|
}
|
|
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 380px;
|
|
gap: 16px;
|
|
}
|
|
.card-title {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 8px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--bg-border);
|
|
background: #0f0f12;
|
|
color: var(--text-muted);
|
|
font-size: 0.78rem;
|
|
font-family: var(--font-mono);
|
|
}
|
|
.panel {
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--bg-border);
|
|
border-radius: var(--radius);
|
|
padding: 14px;
|
|
overflow: hidden;
|
|
}
|
|
.panel h3 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
label {
|
|
display: block;
|
|
margin: 10px 0 6px 0;
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
}
|
|
textarea, select, input[type="text"] {
|
|
width: 100%;
|
|
padding: 10px;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--bg-border);
|
|
background: #0f0f12;
|
|
color: var(--text-primary);
|
|
font-family: var(--font-ui);
|
|
font-size: 0.9rem;
|
|
outline: none;
|
|
}
|
|
textarea { min-height: 140px; resize: vertical; }
|
|
.row {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
margin-top: 10px;
|
|
}
|
|
.row .btn { flex: 0 0 auto; }
|
|
.hint {
|
|
margin-top: 10px;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
line-height: 1.4;
|
|
}
|
|
.status {
|
|
margin-top: 10px;
|
|
padding: 10px;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--bg-border);
|
|
background: #0f0f12;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8rem;
|
|
white-space: pre-wrap;
|
|
}
|
|
.stations {
|
|
max-height: 420px;
|
|
overflow: auto;
|
|
border: 1px solid var(--bg-border);
|
|
border-radius: var(--radius);
|
|
background: #0f0f12;
|
|
}
|
|
.station-toolbar {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 10px;
|
|
align-items: center;
|
|
margin-top: 10px;
|
|
}
|
|
.station-item {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid var(--bg-border);
|
|
align-items: center;
|
|
}
|
|
.station-item:last-child { border-bottom: none; }
|
|
.station-name { flex: 1; }
|
|
.station-meta {
|
|
font-family: var(--font-mono);
|
|
color: var(--text-muted);
|
|
font-size: 0.75rem;
|
|
}
|
|
audio { width: 100%; margin-top: 8px; }
|
|
@media (max-width: 980px) {
|
|
.grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="topbar">
|
|
<div class="title">临时广播</div>
|
|
<div class="row" style="margin-top:0">
|
|
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="panel">
|
|
<div class="card-title">
|
|
<h3 style="margin:0;">内容</h3>
|
|
<span class="badge" id="build_badge">未合成</span>
|
|
</div>
|
|
|
|
<label for="tb_text">广播文本(中文)</label>
|
|
<textarea id="tb_text" placeholder="请输入临时广播内容…"></textarea>
|
|
|
|
<label for="tb_urgency">紧急程度</label>
|
|
<select id="tb_urgency">
|
|
<option value="normal">正常(列车广播优先、可被打断后重播)</option>
|
|
<option value="emergency">紧急(最高优先级,拦停所有广播)</option>
|
|
</select>
|
|
|
|
<div class="row">
|
|
<button class="btn btn-primary" id="btn_compose" onclick="composeTempBroadcast()">合成临时广播(lsts.wav + 内容)</button>
|
|
<button class="btn btn-danger" id="btn_publish" onclick="publishTempBroadcast()" disabled>发布到所选站台</button>
|
|
</div>
|
|
|
|
<div class="hint" id="hint_area">
|
|
需要服务端已存在 lsts.wav(建议放在 /opt/CC-StationBroadcast/Audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
|
|
</div>
|
|
|
|
<div id="preview_area" style="margin-top:12px; display:none;">
|
|
<label>合成结果预览</label>
|
|
<audio id="audio_broadcast" controls></audio>
|
|
</div>
|
|
|
|
<div class="status" id="status_box">就绪</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="card-title">
|
|
<h3 style="margin:0;">目标站台</h3>
|
|
<span class="badge" id="sel_badge">已选 0</span>
|
|
</div>
|
|
<div class="station-toolbar">
|
|
<input type="text" id="station_filter" placeholder="搜索站台…" oninput="applyStationFilter()" />
|
|
<button class="btn" onclick="reloadStations()">刷新</button>
|
|
</div>
|
|
<div class="row">
|
|
<button class="btn" onclick="selectAll(true)">全选</button>
|
|
<button class="btn" onclick="selectAll(false)">全不选</button>
|
|
</div>
|
|
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const ZH_MODEL = "7b64d954530d490a8c40827e618e55c6";
|
|
let state = {
|
|
broadcastId: null,
|
|
broadcastUrl: null
|
|
};
|
|
|
|
function setStatus(msg) {
|
|
const box = document.getElementById("status_box");
|
|
box.textContent = String(msg || "");
|
|
}
|
|
|
|
function getSelectedStations() {
|
|
const nodes = document.querySelectorAll('input[data-station-name="1"]:checked');
|
|
const names = [];
|
|
for (const n of nodes) names.push(n.getAttribute("data-name"));
|
|
return names;
|
|
}
|
|
|
|
function updateSelectedBadge() {
|
|
const n = getSelectedStations().length;
|
|
document.getElementById("sel_badge").textContent = `已选 ${n}`;
|
|
}
|
|
|
|
async function reloadStations() {
|
|
const box = document.getElementById("stations_box");
|
|
box.innerHTML = "";
|
|
setStatus("加载站台列表中…");
|
|
try {
|
|
const resp = await fetch("/api/stations");
|
|
const data = await resp.json();
|
|
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
|
const stations = Array.isArray(data.stations) ? data.stations : [];
|
|
if (stations.length === 0) {
|
|
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
|
|
setStatus("站台列表为空");
|
|
return;
|
|
}
|
|
for (const st of stations) {
|
|
const name = st.name || "";
|
|
const target = st.target || name;
|
|
const code = st.code || "";
|
|
const memberCount = Number(st.memberCount || 1);
|
|
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
|
|
const row = document.createElement("div");
|
|
row.className = "station-item";
|
|
row.innerHTML = `
|
|
<input type="checkbox" data-station-name="1" data-name="${escapeHtml(target)}" onchange="updateSelectedBadge()" />
|
|
<div class="station-name">${escapeHtml(name)}</div>
|
|
<div class="station-meta">${escapeHtml(code)}${memberCount > 1 ? " · 从机 " + memberCount : ""}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div>
|
|
`;
|
|
box.appendChild(row);
|
|
}
|
|
setStatus("站台列表已加载");
|
|
updateSelectedBadge();
|
|
applyStationFilter();
|
|
} catch (e) {
|
|
setStatus("加载站台失败: " + (e?.message || String(e)));
|
|
}
|
|
}
|
|
|
|
function selectAll(on) {
|
|
const nodes = document.querySelectorAll('input[data-station-name="1"]');
|
|
for (const n of nodes) n.checked = !!on;
|
|
updateSelectedBadge();
|
|
}
|
|
|
|
function applyStationFilter() {
|
|
const keyword = String(document.getElementById("station_filter").value || "").trim().toLowerCase();
|
|
const rows = document.querySelectorAll("#stations_box .station-item");
|
|
for (const row of rows) {
|
|
const nameEl = row.querySelector(".station-name");
|
|
const metaEl = row.querySelector(".station-meta");
|
|
const text = `${nameEl ? nameEl.textContent : ""} ${metaEl ? metaEl.textContent : ""}`.toLowerCase();
|
|
row.style.display = keyword === "" || text.includes(keyword) ? "" : "none";
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str || "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
async function composeTempBroadcast() {
|
|
const text = document.getElementById("tb_text").value.trim();
|
|
if (!text) {
|
|
setStatus("请输入广播文本");
|
|
return;
|
|
}
|
|
document.getElementById("btn_publish").disabled = true;
|
|
document.getElementById("btn_compose").disabled = true;
|
|
document.getElementById("build_badge").textContent = "合成中…";
|
|
setStatus("合成临时广播中…");
|
|
try {
|
|
const resp = await fetch("/api/temp-broadcast/compose", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ text, reference_id: ZH_MODEL, prefixName: "lsts" })
|
|
});
|
|
const data = await resp.json();
|
|
if (!data || !data.success) throw new Error(data?.message || "合成失败");
|
|
const preview = document.getElementById("preview_area");
|
|
preview.style.display = "block";
|
|
state.broadcastId = data.broadcastId;
|
|
state.broadcastUrl = data.wavUrl;
|
|
document.getElementById("build_badge").textContent = "已合成";
|
|
document.getElementById("audio_broadcast").src = state.broadcastUrl;
|
|
document.getElementById("btn_publish").disabled = false;
|
|
setStatus(`合成完成: broadcastId=${state.broadcastId} duration=${Number(data.durationSec || 0).toFixed(2)}s`);
|
|
} catch (e) {
|
|
setStatus("合成失败: " + (e?.message || String(e)));
|
|
document.getElementById("build_badge").textContent = "未合成";
|
|
} finally {
|
|
document.getElementById("btn_compose").disabled = false;
|
|
}
|
|
}
|
|
|
|
async function publishTempBroadcast() {
|
|
const stations = getSelectedStations();
|
|
if (!state.broadcastId) {
|
|
setStatus("请先合成临时广播");
|
|
return;
|
|
}
|
|
if (stations.length === 0) {
|
|
setStatus("请至少勾选一个站台");
|
|
return;
|
|
}
|
|
const urgency = document.getElementById("tb_urgency").value;
|
|
document.getElementById("btn_publish").disabled = true;
|
|
setStatus("发布中…");
|
|
try {
|
|
const resp = await fetch("/api/temp-broadcast/publish", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ broadcastId: state.broadcastId, stations, urgency })
|
|
});
|
|
const data = await resp.json();
|
|
if (!data || !data.success) throw new Error(data?.message || "发布失败");
|
|
setStatus(`已发布: 成功=${data.sent || 0} 失败=${data.failed || 0}`);
|
|
} catch (e) {
|
|
setStatus("发布失败: " + (e?.message || String(e)));
|
|
} finally {
|
|
document.getElementById("btn_publish").disabled = false;
|
|
}
|
|
}
|
|
|
|
reloadStations();
|
|
</script>
|
|
</body>
|
|
</html>
|