Files
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

530 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
}
.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;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid var(--bg-border);
}
.panel h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.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;
}
label {
display: block;
margin: 0 0 8px 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
input[type="text"], select, textarea {
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: 80px;
resize: vertical;
font-family: var(--font-ui);
}
select {
width: 100%;
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;
}
input[type="text"]:focus, textarea:focus, select:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.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;
}
@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>控制</h3>
<span class="badge" id="track_badge"></span>
</div>
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/music)</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,.mp3,.ogg,.flac,.m4a,.aac,.opus,.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>
</div>
<script>
function setStatus(msg) {
document.getElementById("status_box").textContent = String(msg || "");
}
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;");
}
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 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 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("没有可用音频(请上传到 /opt/CC-StationBroadcast/Audio/music)");
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("musicAudioFiles", file);
setStatus(`正在上传 ${file.name}…`);
try {
const resp = await fetch("/api/upload/music-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|mp3|ogg|flac|m4a|aac|opus|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>