Remove CTM and inbound prediction

This commit is contained in:
2026-07-31 18:23:24 +08:00
commit e4b4fa5cdb
91 changed files with 15128 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
<!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: 8px;
--font-ui: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
}
body {
font-family: var(--font-ui);
background-color: var(--bg-body);
color: var(--text-primary);
margin: 0;
padding: 24px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--bg-border);
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.025em;
}
.header-meta {
display: flex;
gap: 20px;
align-items: center;
font-size: 0.85rem;
color: var(--text-muted);
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 24px;
flex: 1;
overflow: hidden;
}
.card {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header {
padding: 16px 20px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* Status Styles */
.status-list {
list-style: none;
margin: 0;
padding: 0;
}
.status-item {
padding: 12px 20px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.status-item:last-child {
border-bottom: none;
}
.item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.item-title {
font-weight: 500;
font-size: 0.95rem;
}
.item-sub {
font-size: 0.75rem;
color: var(--text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--bg-border);
}
.dot-online {
background: var(--success);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
}
.dot-offline {
background: var(--danger);
}
.dot-broadcasting {
background: #3b82f6;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
animation: pulse 1s infinite;
}
@keyframes pulse {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.5); }
100% { opacity: 1; transform: scale(1); }
}
/* Train Specifics */
.train-item {
cursor: pointer;
transition: background 0.1s;
}
.train-item:hover {
background: var(--bg-hover);
}
.train-details {
display: none;
background: rgba(0, 0, 0, 0.2);
padding: 12px 20px;
font-size: 0.8rem;
color: var(--text-muted);
border-top: 1px solid var(--bg-border);
}
.train-details.open {
display: block;
}
.trip-log {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 12px;
padding: 4px 0;
border-bottom: 1px dashed var(--bg-border);
}
.trip-log:last-child {
border-bottom: none;
}
/* Statistics & Messages */
.log-list {
padding: 8px;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.log-item {
padding: 12px 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--bg-border);
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.5;
display: flex;
align-items: flex-start;
gap: 12px;
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1);
transition: all 0.2s;
}
.log-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #3f3f46;
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
.log-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
flex-shrink: 0;
margin-top: 2px;
}
.log-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.log-time {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
}
.log-content {
color: var(--text-primary);
word-break: break-all;
}
/* Message Types */
.log-info .log-icon { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
.log-arrival .log-icon { background: rgba(16, 185, 129, 0.15); color: #34d399; }
.log-departure .log-icon { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
.log-broadcast .log-icon { background: rgba(139, 92, 246, 0.15); color: #a78bfa; }
.log-important { border-left: 3px solid #3b82f6; }
.log-broadcast {
background: rgba(139, 92, 246, 0.05);
border-color: rgba(139, 92, 246, 0.2);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
@media (max-width: 1024px) {
.main-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
body {
overflow-y: auto;
height: auto;
}
}
</style>
</head>
<body>
<header>
<h1>实时信息板 - 全局概览</h1>
<div class="header-meta">
<div id="clock" style="font-family: var(--font-mono); font-weight: 500;">00:00:00</div>
<div class="status-badge">
<div id="conn-dot" class="status-dot dot-offline"></div>
<span id="conn-status" style="color: var(--danger)">未连接</span>
</div>
</div>
</header>
<div class="main-grid">
<!-- 车站状态 -->
<div class="card">
<div class="card-header">
<div class="card-title">活动车站</div>
<div id="station-count" style="font-size: 0.75rem; color: var(--text-muted);">0</div>
</div>
<div class="card-content">
<div class="status-list" id="station-list">
<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">正在加载车站信息...</div>
</div>
</div>
</div>
<!-- 列车状态 -->
<div class="card">
<div class="card-header">
<div class="card-title">活跃列车</div>
<div id="train-count" style="font-size: 0.75rem; color: var(--text-muted);">0</div>
</div>
<div class="card-content">
<div class="status-list" id="train-list">
<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">正在加载列车信息...</div>
</div>
</div>
</div>
<!-- 最新消息 -->
<div class="card">
<div class="card-header">
<div class="card-title">最新消息</div>
</div>
<div class="card-content">
<div class="log-list" id="message-list">
<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">暂无消息数据</div>
</div>
</div>
</div>
</div>
<script>
// --- Icons (SVG) ---
const ICONS = {
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
chevronUp: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`,
info: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
arrival: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10l5 5 5-5"/><path d="M12 3v12"/></svg>`,
departure: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14l-5-5-5 5"/><path d="M12 21V9"/></svg>`,
broadcast: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`
};
// --- State ---
let expandedTrains = new Set();
let stationCodeMap = {};
let broadcastingStations = new Set();
let messageLog = [];
const MAX_MESSAGES = 50;
// --- Clock ---
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent = now.toLocaleTimeString();
}
setInterval(updateClock, 1000);
updateClock();
// --- WebSocket ---
const connStatus = document.getElementById('conn-status');
const connDot = document.getElementById('conn-dot');
let ws;
function connect() {
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${wsProto}://${window.location.host}`);
ws.onopen = () => {
connStatus.textContent = '已连接';
connStatus.style.color = 'var(--success)';
connDot.className = 'status-dot dot-online';
ws.send(JSON.stringify({ type: 'console_connect' }));
};
ws.onclose = () => {
connStatus.textContent = '已断开 (重连中...)';
connStatus.style.color = 'var(--danger)';
connDot.className = 'status-dot dot-offline';
setTimeout(connect, 3000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init_state' || data.type === 'state_update') {
updateState(data);
if (data.logs && data.type === 'init_state') {
data.logs.forEach(l => addMessage(l));
}
} else if (data.type === 'log') {
addMessage(data.data);
} else if (data.type === 'stream_start') {
broadcastingStations.add(data.station);
updateStationDots();
} else if (data.type === 'stream_end') {
broadcastingStations.delete(data.station);
updateStationDots();
}
};
}
function addMessage(msg) {
const time = new Date().toLocaleTimeString();
let type = 'log-info';
let icon = ICONS.info;
let important = false;
if (msg.includes('Arrived') || msg.includes('arrival')) {
type = 'log-arrival';
icon = ICONS.arrival;
important = true;
} else if (msg.includes('Departed') || msg.includes('departure')) {
type = 'log-departure';
icon = ICONS.departure;
important = true;
} else if (msg.includes('Broadcast') || msg.includes('Stream')) {
type = 'log-broadcast';
icon = ICONS.broadcast;
}
messageLog.unshift({ time, content: msg, type, icon, important });
if (messageLog.length > MAX_MESSAGES) messageLog.pop();
renderMessages();
}
function renderMessages() {
const container = document.getElementById('message-list');
if (messageLog.length === 0) {
container.innerHTML = '<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">暂无消息数据</div>';
return;
}
container.innerHTML = messageLog.map(m => `
<div class="log-item ${m.type} ${m.important ? 'log-important' : ''}">
<div class="log-icon">${m.icon}</div>
<div class="log-body">
<span class="log-time">${m.time}</span>
<span class="log-content">${m.content}</span>
</div>
</div>
`).join('');
}
function updateState(state) {
if (state.stations) {
const list = document.getElementById('station-list');
list.innerHTML = '';
const stList = Array.isArray(state.stations) ? state.stations : Object.values(state.stations);
document.getElementById('station-count').textContent = stList.length;
stList.forEach(st => {
stationCodeMap[st.name] = st.code;
});
if (stList.length === 0) {
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted);font-size:0.8rem">暂无活动车站</div>';
} else {
stList.forEach(st => {
const isOnline = (Date.now() - st.lastSeen) < 60000;
const isBroadcasting = broadcastingStations.has(st.name);
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
const div = document.createElement('div');
div.className = 'status-item';
div.innerHTML = `
<div class="item-info">
<div class="item-title">
${st.name}
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
</div>
<div class="item-sub">${st.code}${st.platform ? ` - 站台 ${st.platform}` : ''}</div>
</div>
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></div>
`;
list.appendChild(div);
});
}
}
if (state.activeTrains) {
const list = document.getElementById('train-list');
list.innerHTML = '';
document.getElementById('train-count').textContent = state.activeTrains.length;
if (!state.activeTrains || state.activeTrains.length === 0) {
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted);font-size:0.8rem">暂无活跃列车</div>';
} else {
state.activeTrains.forEach(tr => {
const isExpanded = expandedTrains.has(tr.trainId);
const div = document.createElement('div');
div.className = 'status-item train-item';
div.onclick = () => toggleTrain(tr.trainId);
let detailsHtml = '';
if (tr.tripLogs && tr.tripLogs.length > 0) {
detailsHtml = tr.tripLogs.slice().reverse().map(log => {
const time = new Date(log.time).toLocaleTimeString();
const stCode = stationCodeMap[log.station] || log.station;
return `
<div class="trip-log">
<span>${time}</span>
<span style="color:var(--text-primary)">${stCode}</span>
<span style="color:${log.action === 'Arrival' ? 'var(--success)' : 'var(--warning)'}">${log.action === 'Arrival' ? '到达' : '出发'}</span>
</div>
`;
}).join('');
} else {
detailsHtml = '<div style="font-style:italic">暂无运行历史</div>';
}
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
div.innerHTML = `
<div style="width:100%">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div class="item-info">
<div class="item-title">${tr.trainId} <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">${tr.destination || '未知终点'}</span></div>
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : '运行中'}</span></div>
</div>
<div style="color:var(--text-muted)">${isExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
</div>
<div class="train-details ${isExpanded ? 'open' : ''}" id="details-${tr.trainId}">
<div style="margin-bottom:8px;font-weight:600;font-size:0.7rem;text-transform:uppercase;color:var(--text-muted)">运行日志 (Trip Logs)</div>
${detailsHtml}
</div>
</div>
`;
list.appendChild(div);
});
}
}
}
function toggleTrain(id) {
if (expandedTrains.has(id)) expandedTrains.delete(id);
else expandedTrains.add(id);
const details = document.getElementById(`details-${id}`);
if (details) {
details.classList.toggle('open');
}
}
function updateStationDots() {
const dots = document.querySelectorAll('[id^="station-dot-"]');
dots.forEach(dot => {
const name = dot.getAttribute('data-name');
const isOnline = dot.getAttribute('data-online') === 'true';
const isBroadcasting = broadcastingStations.has(name);
dot.className = 'status-dot ' + (isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline'));
});
}
connect();
</script>
</body>
</html>
+742
View File
@@ -0,0 +1,742 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>list.txt Manager</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-hover: #27272a;
--bg-border: #27272a;
--bg-input: #09090b;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--danger: #ef4444;
--success: #10b981;
--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 {
font-family: var(--font-ui);
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
color: var(--text-primary);
margin: 0;
padding: 30px 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.layout-wrapper {
width: 100%;
max-width: 980px;
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid var(--bg-border);
gap: 12px;
flex-wrap: wrap;
}
.header h2 {
margin: 0;
font-weight: 600;
letter-spacing: -0.025em;
background: linear-gradient(to right, #fff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.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);
}
.panel-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
padding-bottom: 12px;
border-bottom: 1px solid var(--bg-border);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
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-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:disabled { opacity: 0.5; cursor: not-allowed; }
.list-container {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 65vh;
overflow-y: auto;
padding-right: 4px;
}
.list-item {
display: grid;
grid-template-columns: 110px 1fr auto;
gap: 14px;
align-items: center;
background: var(--bg-input);
padding: 12px 14px;
border: 1px solid var(--bg-border);
border-radius: 6px;
transition: border-color 0.2s;
}
.list-item:hover { border-color: #52525b; }
.code {
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
}
.tag {
display: inline-flex;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-hover);
font-size: 0.8rem;
margin-right: 4px;
margin-bottom: 4px;
font-family: var(--font-mono);
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover { color: var(--text-primary); background: var(--bg-hover); }
.btn-icon.danger:hover { color: var(--danger); background: rgba(239, 68, 68, 0.1); }
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
backdrop-filter: blur(6px);
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 12px;
width: 92%;
max-width: 540px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.7);
}
.modal-header {
padding: 18px 20px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 1.05rem;
}
.modal-body {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: rgba(0,0,0,0.2);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.form-group { display: flex; flex-direction: column; gap: 8px; }
label { font-size: 0.875rem; font-weight: 500; }
input[type="text"] {
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.95rem;
outline: none;
transition: all 0.2s;
}
input[type="text"]:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.edit-files-list {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
padding: 12px;
min-height: 120px;
max-height: 260px;
overflow-y: auto;
}
.edit-file-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-panel);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--bg-border);
transition: all 0.2s;
gap: 10px;
}
.edit-file-name {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-primary);
word-break: break-all;
}
.edit-file-actions { display: flex; gap: 6px; }
.custom-select-container {
position: relative;
width: 100%;
font-family: var(--font-ui);
font-size: 0.95rem;
user-select: none;
}
.custom-select-trigger {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.custom-select-trigger.active {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.custom-select-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 6px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
z-index: 150;
max-height: 240px;
overflow-y: auto;
display: none;
flex-direction: column;
padding: 4px;
}
.custom-select-options.active { display: flex; }
.custom-select-option {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
color: var(--text-primary);
transition: background 0.1s;
font-family: var(--font-mono);
font-size: 0.85rem;
}
.custom-select-option:hover { background: var(--bg-hover); }
.custom-select-option.selected { background: var(--bg-hover); font-weight: 500; }
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
padding: 14px 18px;
border-radius: 8px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
animation: slideIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-input); border-radius: 4px; }
::-webkit-scrollbar-thumb { background: #52525b; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #71717a; }
* { scrollbar-width: thin; scrollbar-color: #52525b var(--bg-input); }
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<h2>list.txt 管理</h2>
<div class="header-actions">
<a class="btn" href="/">控制台</a>
<a class="btn" href="/generator.html">Generator</a>
</div>
</div>
<div class="panel">
<div class="panel-subtitle">对 uploads/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)</div>
<div class="toolbar">
<button class="btn" onclick="openAddModal()">添加</button>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="btn" onclick="reloadAll()">刷新</button>
<button class="btn btn-primary" id="btn-save" onclick="saveList()">保存到服务器</button>
</div>
</div>
<div class="list-container" id="list-container">
<div style="text-align:center; padding: 40px; color:var(--text-muted)">Loading...</div>
</div>
</div>
</div>
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-header">
<span id="modal-title">Edit</span>
<button class="btn-icon" onclick="closeModal()" aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>站号</label>
<input type="text" id="edit-code" placeholder="例如:01-01 / 01-01/01-02" />
</div>
<div class="form-group">
<label>播放序列(从上到下)</label>
<div class="edit-files-list" id="edit-files-list"></div>
</div>
<div class="form-group">
<label>添加音频文件</label>
<div style="display:flex; gap:10px; align-items:center;">
<div class="custom-select-container" id="custom-file-select">
<div class="custom-select-trigger" onclick="toggleCustomSelect()">
<span id="custom-select-label">-- Select an audio file --</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="custom-select-options" id="custom-select-options"></div>
</div>
<button class="btn" onclick="addFileToSequence()">添加</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="confirmEdit()">确定</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
const ICONS = {
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
trash: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`
};
let listData = [];
let availableFiles = [];
let editingIndex = -1;
let currentSequence = [];
let selectedFileValue = '';
function showToast(title, message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div>
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 250);
}, 2500);
}
function toggleCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
const next = !options.classList.contains('active');
trigger.classList.toggle('active', next);
options.classList.toggle('active', next);
}
function closeCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
trigger.classList.remove('active');
options.classList.remove('active');
}
function selectCustomOption(value, label) {
selectedFileValue = value || '';
const labelEl = document.getElementById('custom-select-label');
if (labelEl) labelEl.textContent = label || '-- Select an audio file --';
const optionsContainer = document.getElementById('custom-select-options');
if (optionsContainer) {
Array.from(optionsContainer.querySelectorAll('.custom-select-option')).forEach(el => {
el.classList.toggle('selected', (el.getAttribute('data-value') || '') === selectedFileValue);
});
}
closeCustomSelect();
}
document.addEventListener('click', (e) => {
const container = document.getElementById('custom-file-select');
if (!container) return;
if (!container.contains(e.target)) closeCustomSelect();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeCustomSelect();
});
async function fetchAvailableFiles() {
const res = await fetch('/api/files');
const data = await res.json();
availableFiles = (data.files || [])
.filter(f => f.type === 'station' || f.type === 'general')
.map(f => f.name)
.sort();
populateFileSelect();
}
function populateFileSelect() {
const optionsContainer = document.getElementById('custom-select-options');
if (!optionsContainer) return;
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
availableFiles.forEach(f => {
const isSelected = selectedFileValue === f ? 'selected' : '';
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${f}')">${f}</div>`;
});
optionsContainer.innerHTML = html;
}
async function loadList() {
const res = await fetch('/api/files/content?path=uploads/list.txt');
const data = await res.json();
listData = [];
if (data.success && data.content) {
const lines = String(data.content).split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([^=]+)==(.+)$/);
if (match) {
listData.push({ code: match[1], files: match[2].split(',').filter(Boolean) });
}
}
}
renderList();
}
function renderList() {
const container = document.getElementById('list-container');
container.innerHTML = '';
listData.sort((a, b) => String(a.code).localeCompare(String(b.code)));
if (listData.length === 0) {
container.innerHTML = '<div style="text-align:center; padding: 40px; color:var(--text-muted)">No entries</div>';
return;
}
listData.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'list-item';
const tagsHtml = (item.files || []).map(f => `<span class="tag">${f}</span>`).join(' ');
div.innerHTML = `
<div class="code">${item.code}</div>
<div>${tagsHtml || '<span style="color:var(--text-muted); font-style: italic;">No files</span>'}</div>
<div style="display:flex; gap:6px;">
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
<button class="btn-icon danger" onclick="removeListItem(${index})" aria-label="Delete">${ICONS.trash}</button>
</div>
`;
container.appendChild(div);
});
}
function removeListItem(index) {
const code = listData[index]?.code;
if (!code) return;
if (!confirm(`删除 ${code} ?`)) return;
listData.splice(index, 1);
renderList();
}
function openAddModal() {
editingIndex = -1;
currentSequence = [];
document.getElementById('modal-title').textContent = '添加站号配置';
document.getElementById('edit-code').value = '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function openEditModal(index) {
editingIndex = index;
const item = listData[index];
currentSequence = [...(item?.files || [])];
document.getElementById('modal-title').textContent = `编辑:${item?.code || ''}`;
document.getElementById('edit-code').value = item?.code || '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function closeModal() {
closeCustomSelect();
document.getElementById('edit-modal').classList.remove('active');
}
function renderSequence() {
const list = document.getElementById('edit-files-list');
list.innerHTML = '';
if (currentSequence.length === 0) {
list.innerHTML = '<div style="color:var(--text-muted); font-size:0.85rem; text-align:center; padding:10px;">No files added yet</div>';
return;
}
currentSequence.forEach((file, idx) => {
const div = document.createElement('div');
div.className = 'edit-file-item';
div.innerHTML = `
<div class="edit-file-name">${file}</div>
<div class="edit-file-actions">
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.35"' : ''} aria-label="Up">${ICONS.up}</button>
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
<button class="btn-icon danger" onclick="removeFileFromSequence(${idx})" aria-label="Remove">${ICONS.trash}</button>
</div>
`;
list.appendChild(div);
});
}
function addFileToSequence() {
const file = selectedFileValue;
if (!file) return;
currentSequence.push(file);
renderSequence();
selectCustomOption('', '-- Select an audio file --');
}
function removeFileFromSequence(idx) {
currentSequence.splice(idx, 1);
renderSequence();
}
function moveFile(idx, dir) {
if (idx + dir < 0 || idx + dir >= currentSequence.length) return;
const temp = currentSequence[idx];
currentSequence[idx] = currentSequence[idx + dir];
currentSequence[idx + dir] = temp;
renderSequence();
}
function confirmEdit() {
const code = document.getElementById('edit-code').value.trim();
if (!code) {
alert('站号不能为空');
return;
}
if (editingIndex === -1) {
if (listData.some(item => item.code === code)) {
alert(`站号 ${code} 已存在`);
return;
}
listData.push({ code, files: [...currentSequence] });
} else {
listData[editingIndex].code = code;
listData[editingIndex].files = [...currentSequence];
}
renderList();
closeModal();
}
async function saveList() {
const btn = document.getElementById('btn-save');
btn.disabled = true;
btn.textContent = 'Saving...';
const content = listData
.filter(item => item.code)
.map(item => `${item.code}==${(item.files || []).join(',')}`)
.join('\n');
try {
const res = await fetch('/api/files/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: 'uploads/list.txt', content: content + '\n' })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Save failed');
showToast('Success', 'list.txt saved');
} catch (e) {
showToast('Error', e?.message || String(e));
} finally {
btn.disabled = false;
btn.textContent = '保存到服务器';
}
}
async function reloadAll() {
try {
await fetchAvailableFiles();
await loadList();
showToast('OK', 'Reloaded');
} catch (e) {
showToast('Error', e?.message || String(e));
}
}
async function init() {
try {
await fetchAvailableFiles();
await loadList();
} catch (e) {
showToast('Error', e?.message || String(e));
}
}
init();
</script>
</body>
</html>
+436
View File
@@ -0,0 +1,436 @@
<!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("&", "&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 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>
+260
View File
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Station Broadcast Streams</title>
<style>
:root {
--bg: #0b1220;
--panel: #111a2e;
--border: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.65);
--good: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
--link: #60a5fa;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
header .meta {
font-size: 12px;
color: var(--muted);
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
main { padding: 16px 20px 28px; }
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.toolbar input {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
width: 280px;
max-width: 100%;
outline: none;
}
.toolbar button, .toolbar a {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
font-size: 12px;
}
.toolbar button:hover, .toolbar a:hover { border-color: rgba(96,165,250,0.6); }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
vertical-align: top;
}
th {
text-align: left;
color: var(--muted);
font-weight: 600;
background: rgba(255,255,255,0.02);
position: sticky;
top: 0;
z-index: 1;
}
tr:hover td { background: rgba(96,165,250,0.06); }
.mono { font-family: var(--mono); }
.status {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 2px rgba(255,255,255,0.08) inset;
}
.dot.idle { background: var(--muted); }
.dot.pushing { background: var(--good); box-shadow: 0 0 12px rgba(34,197,94,0.35); }
.dot.error { background: var(--bad); box-shadow: 0 0 12px rgba(239,68,68,0.35); }
a.link { color: var(--link); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
.err {
color: rgba(255,255,255,0.8);
max-width: 520px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<header>
<h1>Station Broadcast Streams</h1>
<div class="meta">
<span id="meta-mode"></span>
<span id="meta-base"></span>
<span id="meta-updated"></span>
</div>
</header>
<main>
<div class="toolbar">
<input id="filter" placeholder="筛选:站名 / 站号 / 流名" />
<button id="btn-refresh">刷新</button>
<a href="/" target="_blank" rel="noreferrer">控制台</a>
</div>
<table>
<thead>
<tr>
<th style="width: 120px;">流名</th>
<th style="width: 220px;">车站</th>
<th style="width: 110px;">站号</th>
<th>播放地址</th>
<th style="width: 150px;">状态</th>
<th style="width: 210px;">最近时间</th>
<th style="width: 340px;">错误</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</main>
<script>
const tbody = document.getElementById('tbody');
const filterInput = document.getElementById('filter');
const metaMode = document.getElementById('meta-mode');
const metaBase = document.getElementById('meta-base');
const metaUpdated = document.getElementById('meta-updated');
let lastData = null;
function fmtTime(ts) {
if (!ts) return '-';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString();
}
function pickDot(status, lastError) {
if (status === 'pushing' || status === 'building') return 'pushing';
if (lastError) return 'error';
return 'idle';
}
function escapeHtml(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function render(data) {
lastData = data;
metaMode.textContent = `Mode: ${data.streamMode || '-'}`;
metaBase.textContent = `Base: ${data.publicBase || '-'}`;
metaUpdated.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
const q = filterInput.value.trim().toLowerCase();
const rows = (data.streams || []).filter(s => {
if (!q) return true;
return String(s.streamName || '').toLowerCase().includes(q)
|| String(s.stationName || '').toLowerCase().includes(q)
|| String(s.stationCode || '').toLowerCase().includes(q)
|| String(s.hlsUrl || '').toLowerCase().includes(q);
});
tbody.innerHTML = rows.map(s => {
const dot = pickDot(s.status, s.lastError);
const lastTime = s.lastStartAt || s.lastEndAt;
return `
<tr>
<td class="mono">${escapeHtml(s.streamName || '-')}</td>
<td>${escapeHtml(s.stationName || '-')}</td>
<td class="mono">${escapeHtml(s.stationCode || '-')}</td>
<td class="mono">
<a class="link" href="${escapeHtml(s.hlsUrl || '#')}" target="_blank" rel="noreferrer">${escapeHtml(s.hlsUrl || '-')}</a>
</td>
<td>
<span class="status">
<span class="dot ${dot}"></span>
<span class="pill">${escapeHtml(s.status || 'idle')}</span>
</span>
</td>
<td>
<div>Start: ${escapeHtml(fmtTime(s.lastStartAt))}</div>
<div>End: ${escapeHtml(fmtTime(s.lastEndAt))}</div>
</td>
<td class="mono err" title="${escapeHtml(s.lastError || '')}">${escapeHtml(s.lastError || '-')}</td>
</tr>
`;
}).join('');
}
async function refresh() {
try {
const res = await fetch('/api/streams', { cache: 'no-store' });
const data = await res.json();
render(data);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="7" class="mono">Failed to load /api/streams: ${escapeHtml(e?.message || e)}</td></tr>`;
}
}
document.getElementById('btn-refresh').addEventListener('click', refresh);
filterInput.addEventListener('input', () => { if (lastData) render(lastData); });
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
+396
View File
@@ -0,0 +1,396 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stream Manager</title>
<style>
:root {
--bg: #0b1220;
--panel: #111a2e;
--border: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.65);
--good: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
--link: #60a5fa;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
header .meta {
font-size: 12px;
color: var(--muted);
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
main { padding: 16px 20px 28px; }
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.toolbar input {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
width: 320px;
max-width: 100%;
outline: none;
}
.toolbar button, .toolbar a {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
font-size: 12px;
}
.toolbar button:hover, .toolbar a:hover { border-color: rgba(96,165,250,0.6); }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
vertical-align: top;
}
th {
text-align: left;
color: var(--muted);
font-weight: 600;
background: rgba(255,255,255,0.02);
position: sticky;
top: 0;
z-index: 1;
}
tr:hover td { background: rgba(96,165,250,0.06); }
.mono { font-family: var(--mono); }
a.link { color: var(--link); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
.btn-danger { border-color: rgba(239,68,68,0.4); color: rgba(239,68,68,0.95); }
.btn-danger:hover { border-color: rgba(239,68,68,0.9); }
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 10;
}
.modal.open { display: flex; }
.modal-card {
width: 100%;
max-width: 520px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title { font-weight: 600; font-size: 14px; }
.modal-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.modal-body label { font-size: 12px; color: var(--muted); }
.modal-body input {
background: rgba(0,0,0,0.25);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 10px;
border-radius: 8px;
outline: none;
font-family: var(--mono);
}
.modal-footer {
padding: 12px 14px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
</head>
<body>
<header>
<h1>Stream Manager</h1>
<div class="meta">
<span id="meta-base"></span>
<span id="meta-updated"></span>
</div>
</header>
<main>
<div class="toolbar">
<input id="filter" placeholder="筛选:站名 / 站号 / 流名" />
<button id="btn-add">新增站点</button>
<button id="btn-refresh">刷新</button>
<a href="/" target="_blank" rel="noreferrer">控制台</a>
</div>
<table>
<thead>
<tr>
<th style="width: 150px;">流名</th>
<th style="width: 260px;">车站</th>
<th style="width: 140px;">站号</th>
<th>播放地址(m3u8)</th>
<th style="width: 180px;">操作</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</main>
<div class="modal" id="modal">
<div class="modal-card">
<div class="modal-header">
<div class="modal-title" id="modal-title">站点</div>
<button id="modal-close">关闭</button>
</div>
<div class="modal-body">
<div style="display:flex; flex-direction:column; gap:6px;">
<label>站名(唯一键)</label>
<input id="input-name" placeholder="例如:HC-02 站台 / Tokyo Station" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label>站号(会影响流名)</label>
<input id="input-code" placeholder="例如:HC-02_2" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label>重命名(可选,仅编辑时生效)</label>
<input id="input-newname" placeholder="不填则不改站名" />
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel">取消</button>
<button id="btn-save">保存</button>
</div>
</div>
</div>
<script>
const tbody = document.getElementById('tbody');
const filterInput = document.getElementById('filter');
const metaBase = document.getElementById('meta-base');
const metaUpdated = document.getElementById('meta-updated');
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const inputName = document.getElementById('input-name');
const inputCode = document.getElementById('input-code');
const inputNewName = document.getElementById('input-newname');
let mode = 'create';
let editKey = '';
let lastData = null;
function escapeHtml(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function openModal(nextMode, key, row) {
mode = nextMode;
editKey = key || '';
if (mode === 'create') {
modalTitle.textContent = '新增站点';
inputName.disabled = false;
inputName.value = '';
inputCode.value = '';
inputNewName.value = '';
} else {
modalTitle.textContent = '编辑站点';
inputName.disabled = true;
inputName.value = row?.stationName || '';
inputCode.value = row?.stationCode || '';
inputNewName.value = '';
}
modal.classList.add('open');
}
function closeModal() {
modal.classList.remove('open');
}
function render(data) {
lastData = data;
metaBase.textContent = `Base: ${data.publicBase || '-'}`;
metaUpdated.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
const q = filterInput.value.trim().toLowerCase();
const rows = (data.streams || []).filter(s => {
if (!q) return true;
return String(s.streamName || '').toLowerCase().includes(q)
|| String(s.stationName || '').toLowerCase().includes(q)
|| String(s.stationCode || '').toLowerCase().includes(q)
|| String(s.hlsUrl || '').toLowerCase().includes(q);
});
tbody.innerHTML = rows.map(s => {
const hasStation = !!s.stationName;
const actions = hasStation
? `
<button onclick="editStation('${escapeHtml(s.stationName)}')">编辑</button>
<button class="btn-danger" onclick="deleteStation('${escapeHtml(s.stationName)}')">删除站点</button>
`
: `
<button class="btn-danger" onclick="deleteStream('${escapeHtml(s.streamName)}')">清理流</button>
`;
return `
<tr>
<td class="mono">${escapeHtml(s.streamName || '-')}</td>
<td>${escapeHtml(s.stationName || '-')}</td>
<td class="mono">${escapeHtml(s.stationCode || '-')}</td>
<td class="mono">
<a class="link" href="${escapeHtml(s.hlsUrl || '#')}" target="_blank" rel="noreferrer">${escapeHtml(s.hlsUrl || '-')}</a>
</td>
<td style="white-space:nowrap">${actions}</td>
</tr>
`;
}).join('');
}
async function refresh() {
const res = await fetch('/api/streams', { cache: 'no-store' });
const data = await res.json();
render(data);
}
async function editStation(stationName) {
const row = (lastData?.streams || []).find(s => s.stationName === stationName);
openModal('edit', stationName, row);
}
async function deleteStation(stationName) {
if (!confirm(`删除站点:${stationName} ?`)) return;
await fetch(`/api/stations/${encodeURIComponent(stationName)}`, { method: 'DELETE' });
await refresh();
}
async function deleteStream(streamName) {
if (!confirm(`清理流状态:${streamName} ?`)) return;
await fetch(`/api/streams/${encodeURIComponent(streamName)}`, { method: 'DELETE' });
await refresh();
}
async function saveModal() {
const name = inputName.value.trim();
const code = inputCode.value.trim();
const newName = inputNewName.value.trim();
if (mode === 'create') {
if (!name) { alert('站名不能为空'); return; }
const res = await fetch('/api/stations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, code })
});
const data = await res.json();
if (!data.success) { alert(data.message || '保存失败'); return; }
} else {
const res = await fetch(`/api/stations/${encodeURIComponent(editKey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, newName: newName || undefined })
});
const data = await res.json();
if (!data.success) { alert(data.message || '保存失败'); return; }
}
closeModal();
await refresh();
}
document.getElementById('btn-add').addEventListener('click', () => openModal('create'));
document.getElementById('btn-refresh').addEventListener('click', refresh);
filterInput.addEventListener('input', () => { if (lastData) render(lastData); });
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('btn-cancel').addEventListener('click', closeModal);
document.getElementById('btn-save').addEventListener('click', saveModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
+388
View File
@@ -0,0 +1,388 @@
<!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(建议放在 uploads/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 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 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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>