437 lines
17 KiB
HTML
437 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;
|
||
--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;
|
||
}
|
||
.panel {
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--bg-border);
|
||
border-radius: var(--radius);
|
||
padding: 14px;
|
||
overflow: hidden;
|
||
}
|
||
.card-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.panel h3 {
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.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);
|
||
}
|
||
label {
|
||
display: block;
|
||
margin: 10px 0 6px 0;
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
}
|
||
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;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
margin-top: 10px;
|
||
}
|
||
.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;
|
||
}
|
||
@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>控制</h3>
|
||
<span class="badge" id="track_badge">未选择</span>
|
||
</div>
|
||
|
||
<label for="track_select">选择音乐(来自 uploads/audio/general)</label>
|
||
<div class="row" style="margin-top:0">
|
||
<select id="track_select" onchange="onTrackChange()" style="flex:1; min-width: 200px;"></select>
|
||
<button class="btn" onclick="loadTracks()">刷新</button>
|
||
<button class="btn btn-primary" onclick="document.getElementById('local_file_input').click()">从本地选择</button>
|
||
<input type="file" id="local_file_input" accept=".wav,.dfpwm" style="display:none" onchange="uploadLocalFile(this)" />
|
||
</div>
|
||
|
||
<div class="row">
|
||
<label style="margin:0; display:flex; align-items:center; gap:8px; color:var(--text-muted); font-size:0.85rem;">
|
||
<input type="checkbox" id="loop_chk" />
|
||
循环播放
|
||
</label>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button class="btn btn-primary" onclick="startMusic()">开始播放</button>
|
||
<button class="btn btn-danger" onclick="stopMusic()">停止播放</button>
|
||
</div>
|
||
|
||
<div class="status" id="status_box">就绪</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="card-title">
|
||
<h3>目标站台</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>
|
||
function setStatus(msg) {
|
||
document.getElementById("status_box").textContent = String(msg || "");
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str || "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
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 selectAll(on) {
|
||
const nodes = document.querySelectorAll('input[data-station-name="1"]');
|
||
for (const n of nodes) n.checked = !!on;
|
||
updateSelectedBadge();
|
||
}
|
||
|
||
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 code = st.code || "";
|
||
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(name)}" onchange="updateSelectedBadge()" />
|
||
<div class="station-name">${escapeHtml(name)}</div>
|
||
<div class="station-meta">${escapeHtml(code)}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div>
|
||
`;
|
||
box.appendChild(row);
|
||
}
|
||
setStatus("站台列表已加载");
|
||
updateSelectedBadge();
|
||
applyStationFilter();
|
||
} catch (e) {
|
||
setStatus("加载站台失败: " + (e?.message || String(e)));
|
||
}
|
||
}
|
||
|
||
function onTrackChange() {
|
||
const sel = document.getElementById("track_select");
|
||
const v = String(sel.value || "").trim();
|
||
document.getElementById("track_badge").textContent = v ? v : "未选择";
|
||
}
|
||
|
||
async function loadTracks() {
|
||
const sel = document.getElementById("track_select");
|
||
sel.innerHTML = "";
|
||
setStatus("加载音乐列表中…");
|
||
try {
|
||
const resp = await fetch("/api/music/tracks");
|
||
const data = await resp.json();
|
||
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
||
const tracks = Array.isArray(data.tracks) ? data.tracks : [];
|
||
if (tracks.length === 0) {
|
||
sel.innerHTML = '<option value="">(无可用音频)</option>';
|
||
onTrackChange();
|
||
setStatus("没有可用音频(请上传到 uploads/audio/general)");
|
||
return;
|
||
}
|
||
sel.appendChild(new Option("请选择…", ""));
|
||
for (const t of tracks) sel.appendChild(new Option(t, t));
|
||
onTrackChange();
|
||
setStatus(`音乐列表已加载:${tracks.length} 个`);
|
||
} catch (e) {
|
||
sel.innerHTML = '<option value="">(加载失败)</option>';
|
||
onTrackChange();
|
||
setStatus("加载音乐列表失败: " + (e?.message || String(e)));
|
||
}
|
||
}
|
||
|
||
async function uploadLocalFile(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append("generalAudioFiles", file);
|
||
|
||
setStatus(`正在上传 ${file.name}…`);
|
||
try {
|
||
const resp = await fetch("/api/upload/general-audio", {
|
||
method: "POST",
|
||
body: formData
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const text = await resp.text();
|
||
let msg = `服务器返回错误 (${resp.status})`;
|
||
try {
|
||
const errData = JSON.parse(text);
|
||
if (errData.message) msg += `: ${errData.message}`;
|
||
} catch {
|
||
msg += `: ${text.slice(0, 100)}${text.length > 100 ? "..." : ""}`;
|
||
}
|
||
throw new Error(msg);
|
||
}
|
||
|
||
const data = await resp.json();
|
||
if (!data || !data.success) throw new Error(data?.message || "上传失败");
|
||
|
||
setStatus(`文件 ${file.name} 上传成功,正在刷新列表…`);
|
||
await loadTracks();
|
||
|
||
// Try to select the uploaded file
|
||
const baseName = String(file.name || "").replace(/\.(wav|dfpwm)$/i, "").trim();
|
||
const sel = document.getElementById("track_select");
|
||
for (let i = 0; i < sel.options.length; i++) {
|
||
if (sel.options[i].value === baseName) {
|
||
sel.selectedIndex = i;
|
||
onTrackChange();
|
||
break;
|
||
}
|
||
}
|
||
setStatus(`已选择上传的文件: ${baseName}`);
|
||
} catch (e) {
|
||
setStatus("上传失败: " + (e?.message || String(e)));
|
||
} finally {
|
||
input.value = ""; // Clear for next selection
|
||
}
|
||
}
|
||
|
||
async function startMusic() {
|
||
const stations = getSelectedStations();
|
||
const track = String(document.getElementById("track_select").value || "").trim();
|
||
const loop = Boolean(document.getElementById("loop_chk").checked);
|
||
if (!track) {
|
||
setStatus("请选择音乐");
|
||
return;
|
||
}
|
||
if (stations.length === 0) {
|
||
setStatus("请至少勾选一个站台");
|
||
return;
|
||
}
|
||
setStatus("开始播放中…");
|
||
try {
|
||
const resp = await fetch("/api/music/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ stations, track, loop })
|
||
});
|
||
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)));
|
||
}
|
||
}
|
||
|
||
async function stopMusic() {
|
||
const stations = getSelectedStations();
|
||
if (stations.length === 0) {
|
||
setStatus("请至少勾选一个站台");
|
||
return;
|
||
}
|
||
setStatus("停止中…");
|
||
try {
|
||
const resp = await fetch("/api/music/stop", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ stations })
|
||
});
|
||
const data = await resp.json();
|
||
if (!data || !data.success) throw new Error(data?.message || "停止失败");
|
||
setStatus(`已停止:${data.stopped || 0} 个站台`);
|
||
} catch (e) {
|
||
setStatus("停止失败: " + (e?.message || String(e)));
|
||
}
|
||
}
|
||
|
||
reloadStations();
|
||
loadTracks();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|