Remove CTM and inbound prediction
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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("&", "&")
|
||||
.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>
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user