Remove CTM and inbound prediction
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.trae/
|
||||||
|
音频制作/
|
||||||
|
list.txt
|
||||||
|
CloudServer/node_modules/
|
||||||
|
CloudServer/tmp/
|
||||||
|
CloudServer/server-data.json
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* @Author: HenryDu8133 813367384@qq.com
|
||||||
|
* @Date: 2026-02-24 14:42:13
|
||||||
|
* @LastEditors: HenryDu8133 813367384@qq.com
|
||||||
|
* @LastEditTime: 2026-06-07 02:05:41
|
||||||
|
* @FilePath: \StationBroadcastDisplayCN\CloudServer\ecosystem.config.js
|
||||||
|
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps : [{
|
||||||
|
name : "station-cloud-server",
|
||||||
|
script : "./server.js",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
Generated
+1350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudserver",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^2.2.2",
|
||||||
|
"dfpwm": "^1.2.2",
|
||||||
|
"ejs": "^4.0.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"socks-proxy-agent": "^9.0.0",
|
||||||
|
"undici": "^7.24.6",
|
||||||
|
"wavefile": "^11.0.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
File diff suppressed because it is too large
Load Diff
+255
@@ -0,0 +1,255 @@
|
|||||||
|
|
||||||
|
local aukit = require "aukit"
|
||||||
|
|
||||||
|
local speakers = {peripheral.find("speaker")}
|
||||||
|
if #speakers == 0 then error("No speaker attached") end
|
||||||
|
if #speakers == 2 and peripheral.getName(speakers[1]) == "right" and peripheral.getName(speakers[2]) == "left" then speakers = {speakers[2], speakers[1]} end
|
||||||
|
|
||||||
|
local path = ...
|
||||||
|
if not path then error("Usage: austream <file/URL> [arguments for PCM/DFPWM]") end
|
||||||
|
local params = {...}
|
||||||
|
table.remove(params, 1) -- Remove path
|
||||||
|
local v = {}
|
||||||
|
if #params > 0 then
|
||||||
|
-- Try to parse as Lua table string first (original behavior)
|
||||||
|
local paramStr = table.concat(params, " ")
|
||||||
|
-- Remove outer braces if they exist to match original austream format
|
||||||
|
paramStr = paramStr:gsub("^%s*{%s*", ""):gsub("%s*}%s*$", "")
|
||||||
|
|
||||||
|
local fn = load("return {" .. paramStr .. "}", "=unserialize", "t", setmetatable({}, {__index = function(_, idx) return idx end}))
|
||||||
|
if fn then
|
||||||
|
local ok, parsed = pcall(fn)
|
||||||
|
if ok and type(parsed) == "table" then
|
||||||
|
for k, val in pairs(parsed) do v[k] = val end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If that fails, try manual string matching
|
||||||
|
if not next(v) then
|
||||||
|
local matchStr = table.concat(params, ", ")
|
||||||
|
if matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") then
|
||||||
|
v.type = matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]")
|
||||||
|
end
|
||||||
|
if matchStr:match("volume%s*=%s*([%d%.]+)") then v.volume = tonumber(matchStr:match("volume%s*=%s*([%d%.]+)")) end
|
||||||
|
if matchStr:match("streamData%s*=%s*true") then v.streamData = true end
|
||||||
|
if matchStr:match("silent%s*=%s*true") then v.silent = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if v.volume == nil then v.volume = 3 end
|
||||||
|
if type(v.volume) == "number" then
|
||||||
|
if v.volume < 0 then v.volume = 0 end
|
||||||
|
if v.volume > 3 then v.volume = 3 end
|
||||||
|
end
|
||||||
|
local mono = v.mono or #speakers == 1
|
||||||
|
aukit.defaultInterpolation = v.interpolation or aukit.defaultInterpolation
|
||||||
|
local data
|
||||||
|
if path:match("^https?://") then
|
||||||
|
local handle, err = http.get(path, nil, true)
|
||||||
|
if not handle then error("Could not connect to " .. path .. ": " .. err) end
|
||||||
|
local code = handle.getResponseCode()
|
||||||
|
if code ~= 200 then handle.close() error("Could not connect to " .. path .. ": HTTP " .. code) end
|
||||||
|
if v.streamData then
|
||||||
|
local closed = false
|
||||||
|
function data()
|
||||||
|
if closed then return nil end
|
||||||
|
local d = handle.read(48000)
|
||||||
|
if not d then handle.close() closed = true return nil end
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
else
|
||||||
|
data = handle.readAll()
|
||||||
|
handle.close()
|
||||||
|
end
|
||||||
|
elseif path:match("^wss?://") then
|
||||||
|
local ws, err = http.websocket(path)
|
||||||
|
if not ws then error("Failed to connect to websocket: " .. tostring(err)) end
|
||||||
|
|
||||||
|
-- Buffer completely locally to avoid any network stutter
|
||||||
|
local fullBuffer = ""
|
||||||
|
local isDone = false
|
||||||
|
|
||||||
|
local function cleanup()
|
||||||
|
if not isDone then
|
||||||
|
isDone = true
|
||||||
|
if ws then ws.close() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initial wait for the first data chunk from the server (which sends a 1s silence chunk on connect)
|
||||||
|
local startWait = os.startTimer(1.0)
|
||||||
|
while true do
|
||||||
|
local event, p1, p2 = os.pullEvent()
|
||||||
|
if event == "websocket_message" and p1 == path then
|
||||||
|
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
||||||
|
-- Once we get the first message, we can break and start playing
|
||||||
|
break
|
||||||
|
elseif event == "websocket_closed" and p1 == path then
|
||||||
|
cleanup()
|
||||||
|
break
|
||||||
|
elseif event == "timer" and p1 == startWait then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Since WebSocket connection needs to run alongside aukit stream reading,
|
||||||
|
-- we will just use a non-blocking read approach in the data function itself.
|
||||||
|
|
||||||
|
function data(n)
|
||||||
|
n = n or 6000
|
||||||
|
|
||||||
|
if isDone then
|
||||||
|
if #fullBuffer == 0 then return nil end
|
||||||
|
else
|
||||||
|
-- First, read any available messages without blocking
|
||||||
|
-- ws.receive(0) returns immediately. If nil, no message is ready.
|
||||||
|
while true do
|
||||||
|
local msg = ws.receive(0)
|
||||||
|
if msg == nil then break end
|
||||||
|
if type(msg) == "string" then
|
||||||
|
fullBuffer = fullBuffer .. msg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If we still don't have enough data, wait for more
|
||||||
|
-- but only if we know more might be coming (not isDone)
|
||||||
|
if #fullBuffer < n then
|
||||||
|
-- We only wait briefly so we don't block the audio thread too long
|
||||||
|
-- If the server isn't sending data, it means silence or end of stream
|
||||||
|
local timeout = os.startTimer(0.1) -- shorter timeout to keep pulling events
|
||||||
|
while #fullBuffer < n do
|
||||||
|
local event, p1, p2 = os.pullEvent()
|
||||||
|
if event == "websocket_message" and p1 == path then
|
||||||
|
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
||||||
|
elseif event == "websocket_closed" and p1 == path then
|
||||||
|
cleanup()
|
||||||
|
break
|
||||||
|
elseif event == "timer" and p1 == timeout then
|
||||||
|
-- We hit timeout, we must return whatever we have to keep playing
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #fullBuffer == 0 and isDone then return nil end
|
||||||
|
-- Return DFPWM silence (0xAA) if buffer is empty but stream is alive
|
||||||
|
-- This keeps the stream alive without crashing or making noise
|
||||||
|
if #fullBuffer == 0 then return string.rep("\xAA", n) end
|
||||||
|
|
||||||
|
local ret
|
||||||
|
if #fullBuffer >= n then
|
||||||
|
ret = fullBuffer:sub(1, n)
|
||||||
|
fullBuffer = fullBuffer:sub(n + 1)
|
||||||
|
else
|
||||||
|
ret = fullBuffer
|
||||||
|
-- Pad with silence if we don't have enough data to fulfill the request
|
||||||
|
ret = ret .. string.rep("\xAA", n - #ret)
|
||||||
|
fullBuffer = ""
|
||||||
|
end
|
||||||
|
return ret
|
||||||
|
end
|
||||||
|
-- Note: aukit.stream handles the reading internally
|
||||||
|
-- We can use a table with a read function for custom streaming
|
||||||
|
local obj = {
|
||||||
|
read = function(n)
|
||||||
|
local d = data(n)
|
||||||
|
if not d and isDone then cleanup() end
|
||||||
|
return d
|
||||||
|
end,
|
||||||
|
close = cleanup
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
elseif path:match("^rednet://%d+") or path:match("^rednet%+%l+://%d+") then
|
||||||
|
peripheral.find("modem", rednet.open)
|
||||||
|
local proto, id, name = path:match("^rednet%+?(%l*)://(%d+)(/?.*)$")
|
||||||
|
id = tonumber(id)
|
||||||
|
if proto == "" then proto = nil end
|
||||||
|
if path ~= "" and id >= 0 then rednet.send(id, name, proto) end
|
||||||
|
function data()
|
||||||
|
local i, msg
|
||||||
|
repeat i, msg = rednet.receive(proto) until id == -1 or i == id
|
||||||
|
return msg
|
||||||
|
end
|
||||||
|
else
|
||||||
|
path = shell.resolve(...)
|
||||||
|
local file, err = fs.open(path, "rb")
|
||||||
|
if not file then error("Could not open " .. path .. ": " .. err) end
|
||||||
|
if v.streamData then
|
||||||
|
local closed = false
|
||||||
|
function data()
|
||||||
|
if closed then return nil end
|
||||||
|
local d = file.read(48000)
|
||||||
|
if not d then file.close() closed = true return nil end
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
else
|
||||||
|
data = file.readAll()
|
||||||
|
file.close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local iter, length
|
||||||
|
if type(v.type) == "string" then v.type = v.type:lower() end
|
||||||
|
|
||||||
|
local isDfpwm = v.type == "dfpwm" or path:lower():match("%.dfpwm$") or path:match("^wss?://")
|
||||||
|
if isDfpwm then
|
||||||
|
if type(data) == "table" and data.read then
|
||||||
|
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
||||||
|
else
|
||||||
|
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
||||||
|
end
|
||||||
|
elseif v.type == "mdfpwm" or path:lower():match("%.mdfpwm$") then iter, length = aukit.stream.mdfpwm(data, mono)
|
||||||
|
elseif v.type == "wav" or path:lower():match("%.wav$") then iter, length = aukit.stream.wav(data, mono, v.ignoreHeader)
|
||||||
|
elseif v.type == "aiff" or path:lower():match("%.aiff?$") then iter, length = aukit.stream.aiff(data, mono, v.ignoreHeader)
|
||||||
|
elseif v.type == "au" or path:lower():match("%.au$") then iter, length = aukit.stream.au(data, mono, v.ignoreHeader)
|
||||||
|
elseif v.type == "flac" or path:lower():match("%.flac$") then iter, length = aukit.stream.flac(data, mono)
|
||||||
|
elseif v.type == "qoa" or path:lower():match("%.qoa$") then iter, length = aukit.stream.qoa(data, mono)
|
||||||
|
elseif v.type == "pcm" or path:lower():match("%.pcm$") or path:lower():match("%.raw$") or path:match("^rednet%+?%l*://") then iter, length = aukit.stream.pcm(data, v.bitDepth, v.dataType, v.channels, v.sampleRate, v.bigEndian, mono)
|
||||||
|
else error("Unknown file type. Make sure to add the right file extension to the path/URL.") end
|
||||||
|
if length == nil then length = 0 end
|
||||||
|
|
||||||
|
if not v.silent then
|
||||||
|
print("Streaming...")
|
||||||
|
local w = term.getSize()
|
||||||
|
local y = select(2, term.getCursorPos())
|
||||||
|
local fg, bg = colors.toBlit(term.getTextColor()), colors.toBlit(term.getBackgroundColor())
|
||||||
|
term.write(("00:00 %s %02d:%02d"):format(("\127"):rep(w - 12), math.floor(length / 60), length % 60))
|
||||||
|
aukit.play(iter, function(pos)
|
||||||
|
pos = math.min(pos, 5999)
|
||||||
|
local p = pos / length
|
||||||
|
term.setCursorPos(1, y)
|
||||||
|
|
||||||
|
-- 计算基本时间字符串
|
||||||
|
local timeStr = ("%02d:%02d"):format(math.floor(pos / 60), pos % 60)
|
||||||
|
local endTimeStr = ("%02d:%02d"):format(math.floor(length / 60), length % 60)
|
||||||
|
|
||||||
|
if p > 1 then
|
||||||
|
-- 确保空格数量正确
|
||||||
|
local spaces = (" "):rep(w - 12)
|
||||||
|
local text = timeStr .. " " .. spaces .. "--:--"
|
||||||
|
local fgColor = fg:rep(#text)
|
||||||
|
local bgColor = bg:rep(6) .. fg:rep(w - 12) .. bg:rep(6)
|
||||||
|
-- 确保所有参数长度相等
|
||||||
|
if #text == #fgColor and #text == #bgColor then
|
||||||
|
term.blit(text, fgColor, bgColor)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local barWidth = w - 12
|
||||||
|
local progress = math.floor(barWidth * p)
|
||||||
|
progress = math.max(0, math.min(progress, barWidth)) -- 确保进度在有效范围内
|
||||||
|
|
||||||
|
local progressBar = (" "):rep(progress) .. ("\127"):rep(barWidth - progress)
|
||||||
|
local text = timeStr .. " " .. progressBar .. " " .. endTimeStr
|
||||||
|
local fgColor = fg:rep(#text)
|
||||||
|
local bgColor = bg:rep(6) .. fg:rep(progress) .. bg:rep(barWidth - progress + 6)
|
||||||
|
-- 确保所有参数长度相等
|
||||||
|
if #text == #fgColor and #text == #bgColor then
|
||||||
|
term.blit(text, fgColor, bgColor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end, v.volume, table.unpack(speakers))
|
||||||
|
print()
|
||||||
|
else
|
||||||
|
aukit.play(iter, function() end, v.volume, table.unpack(speakers))
|
||||||
|
end
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
|
||||||
|
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
|
||||||
|
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
|
||||||
|
|
||||||
|
local function fetch(url)
|
||||||
|
if not http then
|
||||||
|
return nil, "HTTP API unavailable"
|
||||||
|
end
|
||||||
|
|
||||||
|
if http.checkURL then
|
||||||
|
local ok, err = http.checkURL(url)
|
||||||
|
if not ok then
|
||||||
|
return nil, err or "URL blocked"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local res, err = http.get(url)
|
||||||
|
if not res then
|
||||||
|
return nil, err or "request failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = res.readAll()
|
||||||
|
res.close()
|
||||||
|
|
||||||
|
if type(body) ~= "string" or #body == 0 then
|
||||||
|
return nil, "empty response"
|
||||||
|
end
|
||||||
|
|
||||||
|
return body
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeFile(path, content)
|
||||||
|
local dir = fs.getDir(path)
|
||||||
|
if dir and dir ~= "" and not fs.exists(dir) then
|
||||||
|
fs.makeDir(dir)
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = fs.open(path, "w")
|
||||||
|
if not f then
|
||||||
|
return nil, "cannot open " .. path
|
||||||
|
end
|
||||||
|
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function downloadTo(url, path)
|
||||||
|
write(("Downloading %s... "):format(path))
|
||||||
|
local body, err = fetch(url)
|
||||||
|
if not body then
|
||||||
|
print("FAILED")
|
||||||
|
print(err)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, werr = writeFile(path, body)
|
||||||
|
if not ok then
|
||||||
|
print("FAILED")
|
||||||
|
print(werr)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
print("OK")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local targets = {
|
||||||
|
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
|
||||||
|
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
|
||||||
|
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t in ipairs(targets) do
|
||||||
|
local ok = downloadTo(t.url, t.path)
|
||||||
|
if not ok then
|
||||||
|
print("Install aborted.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
print("Install complete.")
|
||||||
|
print("Run 'reboot' to start StationBroadcast on boot.")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Random Audio Player
|
||||||
|
-- Simplified version that only plays random audio files
|
||||||
|
|
||||||
|
local COLORS = {
|
||||||
|
INFO = colors.white,
|
||||||
|
WARN = colors.yellow,
|
||||||
|
WARNING = colors.yellow,
|
||||||
|
ERROR = colors.red,
|
||||||
|
DEBUG = colors.cyan,
|
||||||
|
SUCCESS = colors.green
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Debug output function
|
||||||
|
local function debugPrint(level, message)
|
||||||
|
local timestamp = os.date("%H:%M:%S")
|
||||||
|
local logMessage = string.format("[%s][%s] %s", timestamp, level, message)
|
||||||
|
local color = COLORS[level] or colors.white
|
||||||
|
local oldColor = term.getTextColor()
|
||||||
|
term.setTextColor(color)
|
||||||
|
print(logMessage)
|
||||||
|
term.setTextColor(oldColor)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add random audio playback function
|
||||||
|
local function playRandomAudio()
|
||||||
|
local audioFiles = {"aqtx", "jzxy"}
|
||||||
|
local randomFile = audioFiles[math.random(#audioFiles)]
|
||||||
|
local path = "/Audio/"..randomFile..".dfpwm"
|
||||||
|
|
||||||
|
if fs.exists(path) then
|
||||||
|
debugPrint("INFO", "Attempting to play: "..path)
|
||||||
|
shell.run("austream "..path.." volume=3.0")
|
||||||
|
debugPrint("INFO", "Finished playing: "..randomFile)
|
||||||
|
else
|
||||||
|
debugPrint("ERROR", "Audio file not found: "..path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Main loop
|
||||||
|
local elapsedSeconds = 0
|
||||||
|
while true do
|
||||||
|
if elapsedSeconds >= 240 then
|
||||||
|
playRandomAudio()
|
||||||
|
elapsedSeconds = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
os.sleep(1)
|
||||||
|
elapsedSeconds = elapsedSeconds + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
|
||||||
|
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
|
||||||
|
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
|
||||||
|
|
||||||
|
local function fetch(url)
|
||||||
|
if not http then
|
||||||
|
return nil, "HTTP API unavailable"
|
||||||
|
end
|
||||||
|
|
||||||
|
if http.checkURL then
|
||||||
|
local ok, err = http.checkURL(url)
|
||||||
|
if not ok then
|
||||||
|
return nil, err or "URL blocked"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local res, err = http.get(url)
|
||||||
|
if not res then
|
||||||
|
return nil, err or "request failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = res.readAll()
|
||||||
|
res.close()
|
||||||
|
|
||||||
|
if type(body) ~= "string" or #body == 0 then
|
||||||
|
return nil, "empty response"
|
||||||
|
end
|
||||||
|
|
||||||
|
return body
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeFile(path, content)
|
||||||
|
local dir = fs.getDir(path)
|
||||||
|
if dir and dir ~= "" and not fs.exists(dir) then
|
||||||
|
fs.makeDir(dir)
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = fs.open(path, "w")
|
||||||
|
if not f then
|
||||||
|
return nil, "cannot open " .. path
|
||||||
|
end
|
||||||
|
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function downloadTo(url, path)
|
||||||
|
write(("Updating %s... "):format(path))
|
||||||
|
local body, err = fetch(url)
|
||||||
|
if not body then
|
||||||
|
print("FAILED")
|
||||||
|
print(err)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, werr = writeFile(path, body)
|
||||||
|
if not ok then
|
||||||
|
print("FAILED")
|
||||||
|
print(werr)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
print("OK")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local targets = {
|
||||||
|
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
|
||||||
|
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
|
||||||
|
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t in ipairs(targets) do
|
||||||
|
local ok = downloadTo(t.url, t.path)
|
||||||
|
if not ok then
|
||||||
|
print("Update aborted.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
print("Update complete.")
|
||||||
Reference in New Issue
Block a user