445 lines
17 KiB
HTML
445 lines
17 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/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>
|
|
|
|
<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 escapeHtml(str) {
|
|
return String(str || "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
async function _generateTTSLocal(text, reference_id) {
|
|
try {
|
|
// Client-side fetch to use local browser's network
|
|
const fishRes = await fetch('https://api.fish.audio/v1/tts', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
text: text,
|
|
reference_id: reference_id,
|
|
model: TTS_MODEL,
|
|
format: "wav"
|
|
})
|
|
});
|
|
|
|
if (!fishRes.ok) {
|
|
let msg = 'Fish Audio API Error';
|
|
try { msg = await fishRes.text(); } catch(e){}
|
|
throw new Error(`[${fishRes.status}] ${msg}`);
|
|
}
|
|
|
|
const audioBlob = await fishRes.blob();
|
|
|
|
// Upload the blob to the server as a temporary file
|
|
const formData = new FormData();
|
|
formData.append('audio', audioBlob, 'temp.wav');
|
|
|
|
const uploadRes = await fetch('/api/tts/upload_temp', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await uploadRes.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('网络跨域拦截(CORS)或连接失败。请确保你的浏览器已安装并开启 CORS Unblock 插件!');
|
|
}
|
|
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>
|