Files
CC-StationBroadcast/CloudServer/public/tempbroadcast.html
T
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

515 lines
20 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: #121214;
--bg-input: #09090b;
--bg-hover: #27272a;
--bg-border: #27272a;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--accent: #fafafa;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius: 8px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--ring-color: rgba(255, 255, 255, 0.2);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 30px 20px;
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
color: var(--text-primary);
font-family: var(--font-ui);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.layout-wrapper {
width: 100%;
max-width: 1280px;
display: flex;
flex-direction: column;
gap: 18px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid var(--bg-border);
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.025em;
background: linear-gradient(to right, #fff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.btn {
border: 1px solid var(--bg-border);
background: transparent;
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
user-select: none;
text-decoration: none;
}
.btn:hover:not(:disabled) { background: var(--bg-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--text-primary);
color: var(--bg-body);
border-color: var(--text-primary);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #d4d4d8;
border-color: #d4d4d8;
}
.btn-danger {
border-color: rgba(239, 68, 68, 0.3);
color: var(--danger);
background: transparent;
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: 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;
padding-bottom: 12px;
border-bottom: 1px solid var(--bg-border);
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
background: var(--bg-hover);
border-radius: 4px;
border: 1px solid var(--bg-border);
color: var(--text-primary);
font-size: 0.78rem;
font-family: var(--font-mono);
font-weight: 500;
min-width: 0;
}
.badge:empty { display: none; }
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
overflow: hidden;
}
.panel h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
label {
display: block;
margin: 0 0 8px 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
textarea, select, input[type="text"] {
width: 100%;
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 0.9rem;
outline: none;
transition: all 0.2s;
height: 40px;
}
textarea {
height: auto;
min-height: 140px;
resize: vertical;
font-family: var(--font-ui);
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'><path fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px 16px;
padding-right: 36px;
cursor: pointer;
}
select > option {
background: var(--bg-panel);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.9rem;
}
textarea:focus, select:focus, input[type="text"]:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.row .btn { flex: 0 0 auto; }
.hint {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
}
.status {
padding: 12px;
border-radius: 6px;
border: 1px solid var(--bg-border);
background: var(--bg-input);
font-family: var(--font-mono);
font-size: 0.8rem;
white-space: pre-wrap;
color: var(--text-primary);
min-height: 2.4em;
}
.stations {
max-height: 420px;
overflow: auto;
border: 1px solid var(--bg-border);
border-radius: 6px;
background: var(--bg-input);
}
.station-toolbar {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
}
.station-item {
display: flex;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--bg-border);
align-items: center;
}
.station-item:last-child { border-bottom: none; }
.station-item:hover { background: var(--bg-hover); }
.station-name { flex: 1; color: var(--text-primary); }
.station-meta {
font-family: var(--font-mono);
color: var(--text-muted);
font-size: 0.75rem;
}
audio { width: 100%; }
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover { background: #52525b; }
</style>
</head>
<body>
<div class="layout-wrapper">
<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/beep/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>
</div>
<script>
const TTS_MODEL = "s2.1-pro-free";
const ZH_REFERENCE_ID = "0aee9f0f5683480d987c9e3c5d2de04c";
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 sanitizeDisplay(s) {
let v = String(s == null ? "" : s)
.replace(/[?¿?]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return v === "" ? "-" : v;
}
function escapeHtml(str) {
let v = sanitizeDisplay(str);
return v
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function _generateTTSLocal(text, reference_id) {
try {
const res = await fetch('/api/tts/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, reference_id, model: TTS_MODEL })
});
if (!res.ok) {
let msg = '服务器返回错误';
try { const errData = await res.json(); msg = errData.message || msg; } catch(e){}
throw new Error(`[${res.status}] ${msg}`);
}
const data = await res.json();
if (!data.success) throw new Error(data.message);
return data;
} catch (err) {
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
throw new Error('网络连接失败。请确保你的服务器代理正常!');
}
throw err;
}
}
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("合成临时广播中(1/2: 生成语音)…");
try {
// 1. 本地生成 TTS 并上传获得临时文件名
const ttsData = await _generateTTSLocal(text, ZH_REFERENCE_ID);
setStatus("合成临时广播中(2/2: 服务端混音)…");
// 2. 告诉服务端进行混音合并
const resp = await fetch("/api/temp-broadcast/build", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentTempName: ttsData.tempName,
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>